# Conflicts: # apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts # apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts # apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts # apps/desktop/e2e/bot-mode-roster-localized.spec.ts # apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts # apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts # apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts # apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts # apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts # apps/desktop/e2e/bot-roster-user-sections.spec.ts # apps/desktop/e2e/bot-routines-pane-narrow.spec.ts # apps/desktop/e2e/bot-row-open-recent-session.spec.ts # apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts # apps/desktop/e2e/group-composer-auto-grow.spec.ts # apps/desktop/e2e/group-create-gate-remote-roster.spec.ts # apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts # apps/desktop/e2e/hosted-room-backend-continuity.spec.ts # apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts # apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts # apps/desktop/e2e/worktree-branch-status.spec.ts # apps/desktop/electron/backend-probes.test.ts # apps/desktop/electron/connection-apply.test.ts # apps/desktop/electron/desktop-electron-pin.test.ts # apps/desktop/electron/desktop-uninstall.test.ts # apps/desktop/electron/gateway-file-download-transport.test.ts # apps/desktop/electron/gateway-stop-before-update.test.ts # apps/desktop/electron/github-api-auth.test.ts # apps/desktop/electron/registry-primary-profile-scope.test.ts # apps/desktop/electron/update-api-check.test.ts # apps/desktop/electron/update-handoff-marker.test.ts # apps/desktop/electron/venv-blocker-scan.test.ts # apps/desktop/scripts/after-extract.test.mjs # apps/desktop/scripts/local-pack-publish.test.mjs # apps/desktop/scripts/tasks-scroll.test.mjs # apps/desktop/src/app/settings/model-settings.test.tsx # apps/desktop/src/app/updates-overlay.blockers.test.tsx # apps/desktop/src/components/desktop-install-overlay.test.tsx # apps/desktop/src/lib/update-copy.test.ts # scripts/ci/check_os_marker_fakes.py # tests-js/desktop-mac-usage-descriptions.test.ts # tests-js/node-engine-alignment.test.ts # tests/agent/lsp/test_install_and_lint_fixes.py # tests/agent/test_command_token_source.py # tests/agent/test_compression_boundary_hook.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/agent/test_custom_provider_ca_probes.py # tests/agent/test_endpoint_blackhole.py # tests/agent/test_estimator_parity.py # tests/agent/test_in_place_compaction.py # tests/agent/test_moa_loop_mode.py # tests/agent/test_model_metadata.py # tests/agent/test_skill_session_platform_gate.py # tests/agent/test_skill_utils.py # tests/agent/test_ssl_ca_guard.py # tests/computer_use/test_doctor.py # tests/cron/test_codex_execution_paths.py # tests/cron/test_cron_bot_chat_delivery.py # tests/cron/test_cron_script.py # tests/cron/test_media_delivery_parity.py # tests/cron/test_misfire_catchup.py # tests/cron/test_parallel_pool.py # tests/cron/test_recurring_eagain_redispatch.py # tests/gateway/test_choice_picker.py # tests/gateway/test_control_socket_windows_live.py # tests/gateway/test_dingtalk.py # tests/gateway/test_feishu.py # tests/gateway/test_feishu_onboard.py # tests/gateway/test_gateway_shutdown.py # tests/gateway/test_matrix.py # tests/gateway/test_model_command_custom_providers.py # tests/gateway/test_reasoning_command.py # tests/gateway/test_runtime_footer.py # tests/gateway/test_session.py # tests/gateway/test_session_hygiene.py # tests/gateway/test_status.py # tests/gateway/test_teams.py # tests/gateway/test_turn_lease.py # tests/gateway/test_whatsapp_connect.py # tests/hermes_cli/test_approvals_command.py # tests/hermes_cli/test_auth_store_lock_concurrent.py # tests/hermes_cli/test_backup.py # tests/hermes_cli/test_banner_git_state.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_compat_manifest_targets.py # tests/hermes_cli/test_computer_use_cli.py # tests/hermes_cli/test_cpr_local_leak.py # tests/hermes_cli/test_dashboard_auth_gate.py # tests/hermes_cli/test_dashboard_procs_kill_grace.py # tests/hermes_cli/test_desktop_lifecycle_windows_live.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_command_install.py # tests/hermes_cli/test_fleet_config_migration_windows_live.py # tests/hermes_cli/test_gateway.py # tests/hermes_cli/test_gateway_platform_gating.py # tests/hermes_cli/test_gateway_restart_loop.py # tests/hermes_cli/test_gateway_task_probe.py # tests/hermes_cli/test_gateway_wsl.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_install_cua_driver.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_command_exports.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_local_runtime.py # tests/hermes_cli/test_local_runtime_updates.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_mcp_reload_confirm_gate.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_npm_engine.py # tests/hermes_cli/test_personality_none.py # tests/hermes_cli/test_pet_toggle.py # tests/hermes_cli/test_plan_reconciliation_windows_live.py # tests/hermes_cli/test_plugin_event_bus.py # tests/hermes_cli/test_plugin_manifest_v2.py # tests/hermes_cli/test_plugin_packs.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py # tests/hermes_cli/test_process_identity.py # tests/hermes_cli/test_profiles.py # tests/hermes_cli/test_profiles_sidebar_cache.py # tests/hermes_cli/test_pty_bridge.py # tests/hermes_cli/test_resolve_turn_limit.py # tests/hermes_cli/test_serve_runtime_inventory.py # tests/hermes_cli/test_session_vacuum_config.py # tests/hermes_cli/test_set_config_value.py # tests/hermes_cli/test_signal_handler_kanban_worker.py # tests/hermes_cli/test_slash_confirm_windows.py # tests/hermes_cli/test_stale_pid_guard.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_telegram_managed_bot.py # tests/hermes_cli/test_tools_config.py # tests/hermes_cli/test_update_apply_shallow_count.py # tests/hermes_cli/test_update_autostash.py # tests/hermes_cli/test_update_concurrent_quarantine.py # tests/hermes_cli/test_update_fetch_failure_classifier.py # tests/hermes_cli/test_update_fleet_probe_resume_token.py # tests/hermes_cli/test_update_handoff_backend_reap.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_host_obligation.py # tests/hermes_cli/test_update_import_guard.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_update_inventory.py # tests/hermes_cli/test_update_launchd_unloaded_gateway.py # tests/hermes_cli/test_update_missing_configured_deps.py # tests/hermes_cli/test_update_modified_notice.py # tests/hermes_cli/test_update_multiplex_migration_hook.py # tests/hermes_cli/test_update_no_gateway_restart.py # tests/hermes_cli/test_update_orphan_backend_reap.py # tests/hermes_cli/test_update_parked_branch_guard.py # tests/hermes_cli/test_update_post_pull_syntax_guard.py # tests/hermes_cli/test_update_receipt.py # tests/hermes_cli/test_update_self_lock.py # tests/hermes_cli/test_update_shim_fail_closed.py # tests/hermes_cli/test_update_shim_self_lock.py # tests/hermes_cli/test_update_sqlite_remediation.py # tests/hermes_cli/test_update_stale_dashboard.py # tests/hermes_cli/test_update_stale_virtualenv.py # tests/hermes_cli/test_update_venv_health.py # tests/hermes_cli/test_update_venv_ownership_preflight.py # tests/hermes_cli/test_update_wedged_gateway.py # tests/hermes_cli/test_update_yes_flag.py # tests/hermes_cli/test_update_zip_two_phase.py # tests/hermes_cli/test_urllib_security.py # tests/hermes_cli/test_ux_messages_auth_config.py # tests/hermes_cli/test_ux_messages_startup.py # tests/hermes_cli/test_venv_holder_classifier.py # tests/hermes_cli/test_verify_console_scripts.py # tests/hermes_cli/test_verify_core_dependencies.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_server_console_ws.py # tests/hermes_cli/test_web_server_ws_ping.py # tests/hermes_cli/test_web_ui_build.py # tests/hermes_state/test_fts_rebuild_admission.py # tests/hermes_state/test_hermes_state.py # tests/plugins/memory/test_memory_lazy_install.py # tests/plugins/test_google_meet_plugin.py # tests/plugins/test_langfuse_plugin.py # tests/plugins/test_security_guidance_plugin.py # tests/plugins/test_transform_llm_output_hook.py # tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_contributor_map.py # tests/scripts/test_run_tests_parallel.py # tests/skills/test_competitor_news_monitor_skill.py # tests/skills/test_document_to_action_items_skill.py # tests/skills/test_google_workspace_setup.py # tests/skills/test_google_workspace_setup_deps.py # tests/skills/test_grounded_citations_skill.py # tests/skills/test_ip_as_logo_skill.py # tests/skills/test_live_dashboard_skill.py # tests/skills/test_mcp_oauth_remote_gateway_skill.py # tests/skills/test_office_document_skills.py # tests/skills/test_openclaw_migration.py # tests/skills/test_product_price_monitor_skill.py # tests/skills/test_scrollcraft_skill.py # tests/skills/test_setup_wizard_generator_skill.py # tests/skills/test_weekly_review_planning_skill.py # tests/test_engines_satisfiable.py # tests/test_fast_safe_load.py # tests/test_hermes_bootstrap.py # tests/test_hermes_constants.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/test_model_tools_async_bridge.py # tests/test_packaging_build_guard.py # tests/test_packaging_metadata.py # tests/test_yaml_indent_consistency.py # tests/tools/test_approval_timeout_overflow.py # tests/tools/test_base_environment.py # tests/tools/test_bot_mode_dm.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_hardening.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_use_cli.py # tests/tools/test_clipboard.py # tests/tools/test_code_execution.py # tests/tools/test_code_execution_modes.py # tests/tools/test_code_execution_windows_env.py # tests/tools/test_computer_use.py # tests/tools/test_delegate_liveness_timeout.py # tests/tools/test_execute_code_approval_cluster.py # tests/tools/test_execution_flag_detection.py # tests/tools/test_fal_common.py # tests/tools/test_file_operations.py # tests/tools/test_file_tools.py # tests/tools/test_file_tools_cwd_resolution.py # tests/tools/test_file_tools_live.py # tests/tools/test_lazy_deps.py # tests/tools/test_lazy_deps_durable_target.py # tests/tools/test_lazy_deps_managed.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_local_tempdir.py # tests/tools/test_macos_protected_search.py # tests/tools/test_mcp_npx_cached_bin.py # tests/tools/test_oneshot_completion_linger.py # tests/tools/test_process_registry.py # tests/tools/test_read_file_schema_gating.py # tests/tools/test_skill_improvements.py # tests/tools/test_skills_sync.py # tests/tools/test_termux_api_detection.py # tests/tools/test_tirith_security.py # tests/tools/test_transcription_tools.py # tests/tools/test_tts_streaming.py # tests/tools/test_wake_word.py # tests/tui_gateway/test_compute_host_borrowed_lease.py # tests/tui_gateway/test_compute_host_turn_protocol.py # tests/tui_gateway/test_isolated_orphan_activity.py # tests/tui_gateway/test_protocol.py # tests/tui_gateway/test_slash_worker_profile_home.py # tests/tui_gateway/test_subprocess_encoding.py # tests/tui_gateway/test_tui_gateway_server.py # ui-tui/src/__tests__/terminalParity.test.ts # ui-tui/src/__tests__/termuxComposerLayout.test.ts # ui-tui/src/__tests__/textInputFastEcho.test.ts
1107 lines
46 KiB
Python
1107 lines
46 KiB
Python
"""Tests for tools/mcp_oauth.py — OAuth 2.1 PKCE support for MCP servers."""
|
|
|
|
import json
|
|
import stat
|
|
import sys
|
|
import time
|
|
from io import BytesIO
|
|
from unittest.mock import patch, MagicMock
|
|
from urllib.parse import quote
|
|
|
|
import pytest
|
|
|
|
import asyncio
|
|
|
|
pytest.importorskip(
|
|
"mcp.client.auth.oauth2",
|
|
reason="MCP SDK 1.26.0+ required for OAuth support",
|
|
)
|
|
|
|
from tools.mcp_oauth import (
|
|
HermesTokenStorage,
|
|
OAuthNonInteractiveError,
|
|
build_oauth_auth,
|
|
remove_oauth_tokens,
|
|
_cached_redirect,
|
|
_can_open_browser,
|
|
_is_interactive,
|
|
_make_callback_handler,
|
|
_paste_callback_reader,
|
|
)
|
|
|
|
|
|
def _find_free_port() -> int:
|
|
import socket
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return s.getsockname()[1]
|
|
|
|
|
|
async def _wait_for_callback():
|
|
"""Await the per-flow waiter on the legacy module-level port (the removed shim)."""
|
|
import tools.mcp_oauth as mod
|
|
return await mod._make_callback_waiter(mod._oauth_port)()
|
|
|
|
|
|
def _set_interactive_stdin(monkeypatch, *, is_tty: bool = True) -> None:
|
|
mock_stdin = MagicMock()
|
|
mock_stdin.isatty.return_value = is_tty
|
|
monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin)
|
|
|
|
|
|
def _hit_callback_when_ready(url: str, timeout: float = 15.0) -> None:
|
|
"""Drive the loopback callback as soon as the waiter's server answers.
|
|
|
|
Polls instead of sleeping a fixed interval: the reserved socket is bound
|
|
but NOT listening until ``_wait_for_callback`` adopts it, so attempts
|
|
before adoption fail fast with a connection error.
|
|
"""
|
|
import urllib.request
|
|
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
urllib.request.urlopen(url, timeout=5)
|
|
return
|
|
except OSError:
|
|
time.sleep(0.01)
|
|
raise AssertionError(f"callback listener never came up: {url}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HermesTokenStorage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestHermesTokenStorage:
|
|
def test_roundtrip_tokens(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("test-server")
|
|
|
|
import asyncio
|
|
|
|
# Initially empty
|
|
assert asyncio.run(storage.get_tokens()) is None
|
|
|
|
# Save and retrieve
|
|
mock_token = MagicMock()
|
|
mock_token.model_dump.return_value = {
|
|
"access_token": "abc123",
|
|
"token_type": "Bearer",
|
|
"refresh_token": "ref456",
|
|
}
|
|
asyncio.run(storage.set_tokens(mock_token))
|
|
|
|
# File exists with correct permissions
|
|
token_path = tmp_path / "mcp-tokens" / "test-server.json"
|
|
assert token_path.exists()
|
|
data = json.loads(token_path.read_text())
|
|
assert data["access_token"] == "abc123"
|
|
|
|
@pytest.mark.platforms("posix") # POSIX mode bits not enforced on Windows
|
|
def test_token_file_created_with_0o600(self, tmp_path, monkeypatch):
|
|
"""Tokens must land on disk at 0o600 with no umask-default exposure window.
|
|
|
|
Regression for the TOCTOU race where ``write_text`` + post-write
|
|
``chmod`` briefly left credentials at the process umask (commonly
|
|
0o644 = world-readable) before tightening to owner-only. Mirrors
|
|
the fix shipped for ``agent/google_oauth.py`` in #19673.
|
|
"""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("perm-test-server")
|
|
|
|
import asyncio
|
|
mock_token = MagicMock()
|
|
mock_token.model_dump.return_value = {
|
|
"access_token": "secret-abc",
|
|
"token_type": "Bearer",
|
|
"refresh_token": "secret-ref",
|
|
}
|
|
asyncio.run(storage.set_tokens(mock_token))
|
|
|
|
token_path = tmp_path / "mcp-tokens" / "perm-test-server.json"
|
|
assert token_path.exists()
|
|
mode = stat.S_IMODE(token_path.stat().st_mode)
|
|
assert mode == 0o600, f"token file mode {oct(mode)} != 0o600 — TOCTOU race regressed"
|
|
|
|
parent_mode = stat.S_IMODE(token_path.parent.stat().st_mode)
|
|
assert parent_mode == 0o700, (
|
|
f"token parent dir mode {oct(parent_mode)} != 0o700 — siblings can traverse"
|
|
)
|
|
|
|
def test_client_info_with_secret_uses_client_secret_post(self, tmp_path, monkeypatch):
|
|
from mcp.shared.auth import OAuthClientInformationFull
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("supabase")
|
|
client_info = OAuthClientInformationFull.model_validate({
|
|
"client_id": "client-id",
|
|
"client_secret": "secret",
|
|
"redirect_uris": ["http://127.0.0.1:12345/callback"],
|
|
})
|
|
|
|
asyncio.run(storage.set_client_info(client_info))
|
|
loaded = asyncio.run(storage.get_client_info())
|
|
|
|
assert loaded is not None
|
|
assert loaded.token_endpoint_auth_method == "client_secret_post"
|
|
client_path = tmp_path / "mcp-tokens" / "supabase.client.json"
|
|
assert json.loads(client_path.read_text())["token_endpoint_auth_method"] == "client_secret_post"
|
|
|
|
def test_client_info_with_secret_and_none_method_is_coerced(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
token_dir = tmp_path / "mcp-tokens"
|
|
token_dir.mkdir(parents=True)
|
|
client_path = token_dir / "supabase.client.json"
|
|
client_path.write_text(json.dumps({
|
|
"client_id": "client-id",
|
|
"client_secret": "secret",
|
|
"redirect_uris": ["http://127.0.0.1:12345/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
}))
|
|
|
|
loaded = asyncio.run(HermesTokenStorage("supabase").get_client_info())
|
|
|
|
assert loaded is not None
|
|
assert loaded.token_endpoint_auth_method == "client_secret_post"
|
|
assert json.loads(client_path.read_text())["token_endpoint_auth_method"] == "client_secret_post"
|
|
|
|
|
|
def test_corrupt_tokens_returns_none(self, tmp_path, monkeypatch):
|
|
import asyncio
|
|
from mcp.shared.auth import OAuthMetadata
|
|
from tools.mcp_oauth_device import DeviceOAuthMetadata
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("bad-server")
|
|
d = tmp_path / "mcp-tokens"
|
|
d.mkdir(parents=True)
|
|
(d / "bad-server.json").write_text("NOT VALID JSON{{{")
|
|
assert asyncio.run(storage.get_tokens()) is None
|
|
for raw in ('NOT VALID JSON{{{', '[]', 'null', '"cached-secret"', '42', 'true', '{}'):
|
|
path = d / "bad-server.meta.json"
|
|
path.write_text(raw)
|
|
assert storage.load_oauth_metadata() is None
|
|
assert path.read_text() == raw
|
|
|
|
metadata = {"issuer": "https://example.com", "token_endpoint": "https://example.com/token",
|
|
"response_types_supported": ["code"], "authorization_endpoint": "https://example.com/auth"}
|
|
for device in (False, True):
|
|
if device:
|
|
metadata.pop("authorization_endpoint")
|
|
metadata["device_authorization_endpoint"] = "https://example.com/device"
|
|
path = d / "bad-server.meta.json"
|
|
path.write_text(json.dumps(metadata))
|
|
loaded = storage.load_oauth_metadata()
|
|
assert type(loaded) is (DeviceOAuthMetadata if device else OAuthMetadata)
|
|
assert str(loaded.token_endpoint) == metadata["token_endpoint"]
|
|
assert json.loads(path.read_text()) == metadata
|
|
|
|
def test_corrupt_tokens_warning_never_echoes_the_token_material(self, tmp_path, monkeypatch, caplog):
|
|
"""A pydantic ValidationError's str() includes the raw input; the corrupt-store warning must
|
|
name the failing fields only (#102308)."""
|
|
import asyncio
|
|
import logging
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("bad-server")
|
|
d = tmp_path / "mcp-tokens"
|
|
d.mkdir(parents=True)
|
|
secret = "sk-live-QQQQQQQQ" # short enough that pydantic's input echo does not elide it
|
|
# access_token must be a str: a one-element list fails validation on THAT field, and pydantic's
|
|
# message echoes the failing field's input — i.e. the token.
|
|
(d / "bad-server.json").write_text(json.dumps({"access_token": [secret], "token_type": "Bearer"}))
|
|
|
|
with caplog.at_level(logging.WARNING, logger="tools.mcp_oauth"):
|
|
assert asyncio.run(storage.get_tokens()) is None
|
|
assert any("Corrupt" in r.message for r in caplog.records)
|
|
assert secret not in caplog.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build_oauth_auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBuildOAuthAuth:
|
|
def test_returns_none_without_sdk(self, monkeypatch):
|
|
import tools.mcp_oauth as mod
|
|
monkeypatch.setattr(mod, "_OAUTH_AVAILABLE", False)
|
|
result = build_oauth_auth("test", "https://example.com")
|
|
assert result is None
|
|
|
|
|
|
def test_scope_passed_through(self, tmp_path, monkeypatch):
|
|
pytest.importorskip("mcp.client.auth")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
_set_interactive_stdin(monkeypatch)
|
|
provider = build_oauth_auth("scoped", "https://example.com/mcp", {
|
|
"scope": "read write admin",
|
|
})
|
|
assert provider is not None
|
|
assert provider.context.client_metadata.scope == "read write admin"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_token_response_accepts_201_created(self, tmp_path, monkeypatch):
|
|
import httpx
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
_set_interactive_stdin(monkeypatch)
|
|
provider = build_oauth_auth("supabase", "https://mcp.supabase.com/mcp")
|
|
assert provider is not None
|
|
response = httpx.Response(201, json={
|
|
"access_token": "access-token",
|
|
"token_type": "Bearer",
|
|
"refresh_token": "refresh-token",
|
|
})
|
|
|
|
await provider._handle_token_response(response)
|
|
|
|
tokens = provider.context.current_tokens
|
|
assert tokens is not None
|
|
assert tokens.access_token == "access-token"
|
|
token_path = tmp_path / "mcp-tokens" / "supabase.json"
|
|
assert token_path.exists()
|
|
assert json.loads(token_path.read_text())["access_token"] == "access-token"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failed_token_exchange_carries_a_bounded_redacted_excerpt(self, tmp_path, monkeypatch):
|
|
"""A non-2xx body names the cause (WAF "Request blocked" vs ``invalid_grant``) without HTML,
|
|
beyond 200 characters or credential-shaped spans (#115329)."""
|
|
import httpx
|
|
from mcp.client.auth.oauth2 import OAuthTokenError
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
_set_interactive_stdin(monkeypatch)
|
|
provider = build_oauth_auth("supabase", "https://mcp.supabase.com/mcp")
|
|
waf = ("<HTML><HEAD><TITLE>ERROR</TITLE></HEAD><BODY><H1>403 ERROR</H1>\n Request blocked.\n"
|
|
"Bearer leaked-bearer-token <PRE>" + "x" * 400 + "</PRE></BODY></HTML>")
|
|
|
|
with pytest.raises(OAuthTokenError) as exc_info:
|
|
await provider._handle_token_response(httpx.Response(403, content=waf.encode()))
|
|
|
|
message = str(exc_info.value)
|
|
assert "Request blocked." in message
|
|
assert "[REDACTED]" in message
|
|
assert "<" not in message and "leaked-bearer-token" not in message
|
|
assert len(message) <= len("Token exchange failed (403): ") + 200
|
|
assert provider.context.current_tokens is None
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Utility functions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestUtilities:
|
|
def test_can_open_browser_false_in_ssh(self, monkeypatch):
|
|
monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 1234 22")
|
|
assert _can_open_browser() is False
|
|
|
|
def test_can_open_browser_true_with_display(self, monkeypatch):
|
|
# No ``os.name`` pin: on Linux this exercises the DISPLAY branch for
|
|
# real, and on macOS/Windows the function early-returns True anyway —
|
|
# the assertion holds on every host without faking one.
|
|
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
|
monkeypatch.delenv("SSH_TTY", raising=False)
|
|
monkeypatch.setenv("DISPLAY", ":0")
|
|
assert _can_open_browser() is True
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Path traversal protection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPathTraversal:
|
|
"""Verify server_name is sanitized to prevent path traversal."""
|
|
|
|
def test_dots_and_slashes_sanitized(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("../../../etc/passwd")
|
|
path = storage._tokens_path()
|
|
resolved = path.resolve()
|
|
assert resolved.is_relative_to((tmp_path / "mcp-tokens").resolve())
|
|
|
|
def test_normal_name_unchanged(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("my-mcp-server")
|
|
assert "my-mcp-server.json" in str(storage._tokens_path())
|
|
|
|
def test_special_chars_sanitized(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("server@host:8080/path")
|
|
path = storage._tokens_path()
|
|
assert "@" not in path.name
|
|
assert ":" not in path.name
|
|
assert "/" not in path.stem
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Callback handler isolation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCallbackHandlerIsolation:
|
|
"""Verify concurrent OAuth flows don't share state."""
|
|
|
|
def _fake_get(self, HandlerClass, path):
|
|
handler = HandlerClass.__new__(HandlerClass)
|
|
handler.path = path
|
|
handler.wfile = BytesIO()
|
|
handler.send_response = MagicMock()
|
|
handler.send_header = MagicMock()
|
|
handler.end_headers = MagicMock()
|
|
handler.do_GET()
|
|
|
|
def test_handler_writes_to_own_result(self):
|
|
HandlerClass, result = _make_callback_handler()
|
|
assert result["auth_code"] is None
|
|
|
|
self._fake_get(HandlerClass, "/callback?code=test123&state=mystate")
|
|
|
|
assert result["auth_code"] == "test123"
|
|
assert result["state"] == "mystate"
|
|
|
|
def test_handler_captures_error(self):
|
|
HandlerClass, result = _make_callback_handler()
|
|
|
|
self._fake_get(HandlerClass, "/callback?error=access_denied")
|
|
|
|
assert result["auth_code"] is None
|
|
assert result["error"] == "access_denied"
|
|
|
|
|
|
class TestCallbackHandlerErrorEscaping:
|
|
"""Regression: a hostile ``error`` parameter must be HTML-escaped before
|
|
being reflected into the callback response body (reflected XSS)."""
|
|
|
|
def test_hostile_error_is_escaped_in_response_body(self):
|
|
HandlerClass, result = _make_callback_handler()
|
|
|
|
handler = HandlerClass.__new__(HandlerClass)
|
|
handler.path = "/callback?error=" + quote("<script>alert(1)</script>")
|
|
handler.wfile = BytesIO()
|
|
handler.send_response = MagicMock()
|
|
handler.send_header = MagicMock()
|
|
handler.end_headers = MagicMock()
|
|
handler.do_GET()
|
|
|
|
body = handler.wfile.getvalue().decode("utf-8")
|
|
assert "<script>" not in body
|
|
assert "<script>alert(1)</script>" in body
|
|
# The raw (unescaped) value is still captured for programmatic use.
|
|
assert result["error"] == "<script>alert(1)</script>"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TOCTOU port reservation (#22161)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCallbackPortReservation:
|
|
"""The socket picked at selection time stays bound until callback bind.
|
|
|
|
_find_free_port() closed its probe socket before HTTPServer re-bound the
|
|
port, leaving a race window where another process could steal it
|
|
(#22161). _reserve_callback_port() keeps the bound socket parked in
|
|
_reserved_sockets until _wait_for_callback adopts it.
|
|
"""
|
|
|
|
def test_reserved_port_cannot_be_stolen(self):
|
|
import socket as sock
|
|
import tools.mcp_oauth as mod
|
|
|
|
port = mod._reserve_callback_port()
|
|
try:
|
|
# The reservation holds the bind — a competing bind must fail.
|
|
thief = sock.socket(sock.AF_INET, sock.SOCK_STREAM)
|
|
with pytest.raises(OSError):
|
|
thief.bind(("127.0.0.1", port))
|
|
thief.close()
|
|
finally:
|
|
reserved = mod._reserved_sockets.pop(port, None)
|
|
if reserved is not None:
|
|
reserved.close()
|
|
|
|
def test_pinned_port_is_not_reserved(self):
|
|
import tools.mcp_oauth as mod
|
|
|
|
cfg: dict = {"redirect_port": 49399}
|
|
port = mod._configure_callback_port(cfg)
|
|
assert port == 49399
|
|
assert cfg["_resolved_port"] == 49399
|
|
assert 49399 not in mod._reserved_sockets
|
|
|
|
@pytest.mark.usefixtures("require_mcp_2_sdk") # asserts the 2.0-only AuthorizationCodeResult.code
|
|
def test_wait_for_callback_adopts_reserved_socket(self, monkeypatch):
|
|
"""E2E: reserve → _wait_for_callback binds the SAME socket and the
|
|
callback round-trips through it."""
|
|
import asyncio
|
|
import threading
|
|
import tools.mcp_oauth as mod
|
|
|
|
# cimd: false keeps this on the ephemeral branch. A CIMD-eligible
|
|
# config would take a pinned port instead, and this test would pass
|
|
# while never exercising _reserve_callback_port at all.
|
|
cfg: dict = {"cimd": False}
|
|
port = mod._configure_callback_port(cfg)
|
|
monkeypatch.setattr(mod, "_is_interactive", lambda: False)
|
|
# Bypass the non-interactive guard — this test drives the flow directly.
|
|
monkeypatch.setattr(mod, "_raise_if_non_interactive", lambda lead: None)
|
|
|
|
async def drive():
|
|
task = asyncio.create_task(_wait_for_callback())
|
|
threading.Thread(
|
|
target=_hit_callback_when_ready,
|
|
args=(f"http://127.0.0.1:{port}/callback?code=abc123&state=xyz",),
|
|
daemon=True,
|
|
).start()
|
|
return await asyncio.wait_for(task, timeout=20)
|
|
|
|
# mcp 2.0's callback_handler contract returns an
|
|
# AuthorizationCodeResult, not the legacy (code, state) tuple.
|
|
result = asyncio.run(drive())
|
|
assert result.code == "abc123"
|
|
assert result.state == "xyz"
|
|
# Reservation was consumed by adoption.
|
|
assert port not in mod._reserved_sockets
|
|
|
|
@pytest.mark.usefixtures("require_mcp_2_sdk") # asserts the 2.0-only AuthorizationCodeResult.code
|
|
def test_concurrent_flows_keep_their_own_callback_ports(self, monkeypatch):
|
|
"""#34260: flow A's waiter listens on A's port even after flow B
|
|
overwrites the legacy module-level global.
|
|
|
|
This is the callback-side sibling of the #44588 redirect-handler fix:
|
|
without a per-flow waiter, A's callback wait would bind B's port and
|
|
A's redirect (pointing at A's port) would never be received.
|
|
"""
|
|
import asyncio
|
|
import threading
|
|
import tools.mcp_oauth as mod
|
|
|
|
monkeypatch.setattr(mod, "_is_interactive", lambda: False)
|
|
monkeypatch.setattr(mod, "_raise_if_non_interactive", lambda lead: None)
|
|
|
|
# cimd: false keeps both flows on ephemeral ports, which is where the
|
|
# #34260 clobbering happens; the pinned range has its own coverage in
|
|
# tests/tools/test_mcp_cimd.py.
|
|
cfg_a: dict = {"cimd": False}
|
|
port_a = mod._configure_callback_port(cfg_a)
|
|
waiter_a = mod._make_callback_waiter(port_a)
|
|
# Flow B configures afterwards — overwrites mod._oauth_port.
|
|
cfg_b: dict = {"cimd": False}
|
|
port_b = mod._configure_callback_port(cfg_b)
|
|
assert mod._oauth_port == port_b != port_a
|
|
|
|
async def drive():
|
|
task = asyncio.create_task(waiter_a())
|
|
# The redirect goes to flow A's port — where A's waiter must be
|
|
# listening despite the clobbered global.
|
|
threading.Thread(
|
|
target=_hit_callback_when_ready,
|
|
args=(f"http://127.0.0.1:{port_a}/callback?code=flowA&state=sA",),
|
|
daemon=True,
|
|
).start()
|
|
return await asyncio.wait_for(task, timeout=20)
|
|
|
|
try:
|
|
result = asyncio.run(drive())
|
|
finally:
|
|
leftover = mod._reserved_sockets.pop(port_b, None)
|
|
if leftover is not None:
|
|
leftover.close()
|
|
assert result.code == "flowA"
|
|
assert result.state == "sA"
|
|
|
|
@staticmethod
|
|
def _seed_client_info(tmp_path, payload):
|
|
"""Write *payload* verbatim to the real ``mcp-tokens/srv.client.json`` under a temp home."""
|
|
storage = HermesTokenStorage("srv", hermes_home=tmp_path)
|
|
path = storage._client_info_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
return storage
|
|
|
|
@pytest.mark.parametrize("bad_uri", [
|
|
"http://127.0.0.1:abc/callback", # .port raises: non-numeric
|
|
"http://127.0.0.1:99999/callback", # .port raises: out of range
|
|
"http://[bad/callback", # urlparse itself raises: bad IPv6 bracket
|
|
])
|
|
def test_cached_redirect_skips_malformed_entries(self, tmp_path, bad_uri):
|
|
"""DCR-supplied redirect_uris persist to client.json. urlparse() alone does not
|
|
validate ports — .port is lazy and raises ValueError on access — so the try/except
|
|
around urlparse never fires. A poisoned entry must be skipped like every other
|
|
malformed one, not crash the whole OAuth flow (#112568)."""
|
|
storage = self._seed_client_info(tmp_path, {
|
|
"client_id": "client-a",
|
|
"redirect_uris": [bad_uri, "http://127.0.0.1:1455/callback", "https://proxy.example.com/cb"]})
|
|
assert _cached_redirect(storage) == ("https://proxy.example.com/cb", 1455)
|
|
|
|
@pytest.mark.parametrize("payload", [
|
|
["not", "a", "dict"], # non-dict client.json: .get would AttributeError
|
|
{"redirect_uris": 123}, # non-iterable redirect_uris: for would TypeError
|
|
{"redirect_uris": {"a": 1}}, # dict redirect_uris: iterate keys, nothing matches
|
|
{"redirect_uris": None}, # explicit null
|
|
{"client_id": "c"}, # missing key entirely
|
|
])
|
|
def test_cached_redirect_tolerates_misshaped_client_info(self, tmp_path, payload):
|
|
"""_read_json returns whatever the file holds — the crash class isn't limited to
|
|
bad URIs inside a well-formed list. Any misshaped payload must degrade to
|
|
(None, None), not propagate AttributeError/TypeError through the OAuth flow (#112568)."""
|
|
storage = self._seed_client_info(tmp_path, payload)
|
|
assert _cached_redirect(storage) == (None, None)
|
|
|
|
|
|
@pytest.mark.parametrize("payload", [
|
|
{"client_id": "c", "redirect_uris": ["http://127.0.0.1:abc/callback"]}, # the issue's repro
|
|
["x"], # non-dict client.json
|
|
])
|
|
def test_malformed_client_info_flow_reserves_fresh_ephemeral_port(self, tmp_path, payload):
|
|
"""Flow-level: the login path calls _configure_callback_port(cfg, storage) and the SDK
|
|
then calls storage.get_client_info(). A poisoned client.json must fall through to a
|
|
freshly reserved ephemeral port and read as "no registration", so the flow re-registers
|
|
instead of crashing on every attempt until the file is removed by hand (#112568)."""
|
|
import tools.mcp_oauth as mod
|
|
|
|
storage = self._seed_client_info(tmp_path, payload)
|
|
cfg: dict = {"cimd": False} # keep the fresh-port branch, as the sibling tests do
|
|
port = mod._configure_callback_port(cfg, storage)
|
|
try:
|
|
assert port == cfg["_resolved_port"] > 0
|
|
assert port in mod._reserved_sockets # only a truly fresh pick is parked
|
|
assert asyncio.run(storage.get_client_info()) is None
|
|
finally:
|
|
reserved = mod._reserved_sockets.pop(port, None)
|
|
if reserved is not None:
|
|
reserved.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# remove_oauth_tokens
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRemoveOAuthTokens:
|
|
def test_removes_files(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
d = tmp_path / "mcp-tokens"
|
|
d.mkdir()
|
|
(d / "myserver.json").write_text("{}")
|
|
(d / "myserver.client.json").write_text("{}")
|
|
|
|
remove_oauth_tokens("myserver")
|
|
|
|
assert not (d / "myserver.json").exists()
|
|
assert not (d / "myserver.client.json").exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client-change token invalidation (port of cline/cline#12983)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestInvalidateTokensOnClientChange:
|
|
"""Editing oauth.client_id/client_secret must discard tokens minted
|
|
under the previous client identity (they can only fail with
|
|
invalid_client), while an unchanged identity preserves them."""
|
|
|
|
def _seed(self, tmp_path, monkeypatch, client_id="client-a",
|
|
client_secret=None):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("chg-server")
|
|
d = tmp_path / "mcp-tokens"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
info = {"client_id": client_id, "redirect_uris": ["http://localhost:1455/callback"]}
|
|
if client_secret:
|
|
info["client_secret"] = client_secret
|
|
(d / "chg-server.client.json").write_text(json.dumps(info))
|
|
(d / "chg-server.json").write_text(json.dumps({
|
|
"access_token": "old-token", "token_type": "Bearer",
|
|
}))
|
|
(d / "chg-server.meta.json").write_text(json.dumps({
|
|
"issuer": "https://idp.example",
|
|
"authorization_endpoint": "https://idp.example/auth",
|
|
"token_endpoint": "https://idp.example/token",
|
|
}))
|
|
return storage, d
|
|
|
|
def test_changed_client_id_drops_tokens(self, tmp_path, monkeypatch):
|
|
from tools.mcp_oauth import _invalidate_tokens_on_client_change
|
|
storage, d = self._seed(tmp_path, monkeypatch)
|
|
_invalidate_tokens_on_client_change(storage, "client-b", None)
|
|
assert not (d / "chg-server.json").exists()
|
|
assert not (d / "chg-server.meta.json").exists()
|
|
# client.json is left for _maybe_preregister_client to overwrite
|
|
assert (d / "chg-server.client.json").exists()
|
|
|
|
def test_changed_secret_drops_tokens(self, tmp_path, monkeypatch):
|
|
from tools.mcp_oauth import _invalidate_tokens_on_client_change
|
|
storage, d = self._seed(tmp_path, monkeypatch,
|
|
client_secret="old-secret")
|
|
_invalidate_tokens_on_client_change(storage, "client-a", "new-secret")
|
|
assert not (d / "chg-server.json").exists()
|
|
|
|
def test_same_client_preserves_tokens(self, tmp_path, monkeypatch):
|
|
from tools.mcp_oauth import _invalidate_tokens_on_client_change
|
|
storage, d = self._seed(tmp_path, monkeypatch,
|
|
client_secret="sec")
|
|
_invalidate_tokens_on_client_change(storage, "client-a", "sec")
|
|
assert (d / "chg-server.json").exists()
|
|
assert (d / "chg-server.meta.json").exists()
|
|
|
|
def test_no_prior_client_info_is_noop(self, tmp_path, monkeypatch):
|
|
from tools.mcp_oauth import _invalidate_tokens_on_client_change
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("fresh-server")
|
|
d = tmp_path / "mcp-tokens"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "fresh-server.json").write_text(json.dumps({
|
|
"access_token": "tok", "token_type": "Bearer",
|
|
}))
|
|
_invalidate_tokens_on_client_change(storage, "client-x", None)
|
|
# No recorded client identity -> nothing provably stale.
|
|
assert (d / "fresh-server.json").exists()
|
|
|
|
def test_preregister_flow_invalidates_end_to_end(self, tmp_path, monkeypatch):
|
|
"""_maybe_preregister_client wires the check in before overwriting
|
|
client.json — the full config-edit flow drops stale tokens."""
|
|
pytest.importorskip("mcp")
|
|
from tools.mcp_oauth import (
|
|
_build_client_metadata, _maybe_preregister_client,
|
|
)
|
|
storage, d = self._seed(tmp_path, monkeypatch)
|
|
cfg = {"client_id": "client-b", "_resolved_port": 1455}
|
|
meta = _build_client_metadata(dict(cfg))
|
|
_maybe_preregister_client(storage, cfg, meta)
|
|
assert not (d / "chg-server.json").exists(), (
|
|
"tokens minted under client-a must not survive switch to client-b"
|
|
)
|
|
info = json.loads((d / "chg-server.client.json").read_text())
|
|
assert info["client_id"] == "client-b"
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Non-interactive / startup-safety tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestIsInteractive:
|
|
"""_is_interactive() detects headless/daemon/container environments."""
|
|
|
|
def test_suppress_interactive_oauth_disables_stdin_prompts(self, monkeypatch):
|
|
import tools.mcp_oauth as mod
|
|
|
|
mock_stdin = MagicMock()
|
|
mock_stdin.isatty.return_value = True
|
|
monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin)
|
|
|
|
assert _is_interactive() is True
|
|
with mod.suppress_interactive_oauth():
|
|
assert _is_interactive() is False
|
|
assert _is_interactive() is True
|
|
|
|
def test_suppression_propagates_across_run_coroutine_threadsafe(self, monkeypatch):
|
|
"""#35927 core: suppression set on the discovery thread MUST reach the
|
|
coroutine asyncio runs on a *different* (event-loop) thread — that is
|
|
where the OAuth callback / _is_interactive() actually executes via
|
|
run_coroutine_threadsafe. A threading.local would NOT propagate here
|
|
(the original fix's defect); a ContextVar does."""
|
|
import asyncio
|
|
import threading
|
|
import tools.mcp_oauth as mod
|
|
|
|
mock_stdin = MagicMock()
|
|
mock_stdin.isatty.return_value = True
|
|
monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin)
|
|
|
|
loop = asyncio.new_event_loop()
|
|
loop_thread = threading.Thread(target=loop.run_forever, daemon=True)
|
|
loop_thread.start()
|
|
result = {}
|
|
try:
|
|
async def _probe_on_loop_thread():
|
|
# runs on the loop thread, NOT the one that set suppression
|
|
return (threading.current_thread() is not discovery_thread,
|
|
_is_interactive())
|
|
|
|
discovery_thread = None
|
|
|
|
def _discovery():
|
|
nonlocal discovery_thread
|
|
discovery_thread = threading.current_thread()
|
|
with mod.suppress_interactive_oauth():
|
|
fut = asyncio.run_coroutine_threadsafe(
|
|
_probe_on_loop_thread(), loop
|
|
)
|
|
result["cross_thread"], result["interactive"] = fut.result(timeout=5)
|
|
|
|
dt = threading.Thread(target=_discovery)
|
|
dt.start()
|
|
dt.join()
|
|
finally:
|
|
loop.call_soon_threadsafe(loop.stop)
|
|
|
|
assert result["cross_thread"] is True, "probe must run on the loop thread"
|
|
# The whole point: suppression must hold on the loop thread.
|
|
assert result["interactive"] is False
|
|
|
|
|
|
class TestWaitForCallbackNoBlocking:
|
|
"""_wait_for_callback() must never call input() — it raises instead."""
|
|
|
|
def test_raises_on_timeout_instead_of_input(self, monkeypatch):
|
|
"""Interactive session: when no auth code arrives, raises on timeout.
|
|
|
|
Marked interactive so the fail-fast non-interactive guard (#57836)
|
|
does not short-circuit — this test exercises the timeout path.
|
|
"""
|
|
import tools.mcp_oauth as mod
|
|
import asyncio
|
|
|
|
mod._oauth_port = _find_free_port()
|
|
monkeypatch.setattr(mod, "_is_interactive", lambda: True)
|
|
# EOF on the paste reader so only the HTTP-listener timeout drives it.
|
|
monkeypatch.setattr("sys.stdin", MagicMock(readline=lambda: ""))
|
|
|
|
async def instant_sleep(_seconds):
|
|
pass
|
|
|
|
with patch.object(mod.asyncio, "sleep", instant_sleep):
|
|
with patch("builtins.input", side_effect=AssertionError("input() must not be called")):
|
|
with pytest.raises(OAuthNonInteractiveError, match="callback timed out"):
|
|
asyncio.run(_wait_for_callback())
|
|
|
|
|
|
class TestBuildOAuthAuthNonInteractive:
|
|
"""build_oauth_auth() in non-interactive mode."""
|
|
|
|
def test_noninteractive_without_cached_tokens_fails_fast(self, tmp_path, monkeypatch):
|
|
"""Without cached tokens, non-interactive mode skips browser auth."""
|
|
pytest.importorskip("mcp.client.auth")
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
mock_stdin = MagicMock()
|
|
mock_stdin.isatty.return_value = False
|
|
monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin)
|
|
|
|
with pytest.raises(OAuthNonInteractiveError, match="non-interactive"):
|
|
build_oauth_auth("atlassian", "https://mcp.atlassian.com/v1/mcp")
|
|
|
|
|
|
class TestNonInteractiveFailFastAtCallbackBoundary:
|
|
"""#57836: a cached-but-unusable token (expired/revoked, refresh rejected)
|
|
makes the MCP SDK fall through to the authorization-code flow even though
|
|
build_oauth_auth's token-file guard passed. In a non-interactive context
|
|
(systemd gateway, cron, background discovery) that flow must fail fast at
|
|
the redirect/callback boundary — never launch a browser flow or bind a
|
|
callback listener, and never block for the full timeout — so gateway
|
|
startup is not gated on an unusable optional MCP server, and retries do not
|
|
collide on the callback port ('Address already in use').
|
|
"""
|
|
|
|
def test_wait_for_callback_rejects_before_binding_when_noninteractive(self, monkeypatch):
|
|
"""No listener bound and no poll loop entered when non-interactive."""
|
|
import tools.mcp_oauth as mod
|
|
import asyncio
|
|
|
|
mod._oauth_port = _find_free_port()
|
|
monkeypatch.setattr(mod, "_is_interactive", lambda: False)
|
|
|
|
# Binding the callback listener or entering the poll loop is the bug.
|
|
fake_server = MagicMock(side_effect=AssertionError("must not bind callback listener"))
|
|
monkeypatch.setattr(mod, "HTTPServer", fake_server)
|
|
|
|
async def no_sleep(_seconds):
|
|
raise AssertionError("must not wait for the callback timeout")
|
|
monkeypatch.setattr(mod.asyncio, "sleep", no_sleep)
|
|
|
|
with pytest.raises(OAuthNonInteractiveError, match="interactive session"):
|
|
asyncio.run(_wait_for_callback())
|
|
fake_server.assert_not_called()
|
|
|
|
def test_redirect_handler_rejects_and_does_not_open_browser(self, monkeypatch, capsys):
|
|
"""Non-interactive redirect must not print an auth URL or open a browser."""
|
|
import tools.mcp_oauth as mod
|
|
import asyncio
|
|
|
|
monkeypatch.setattr(mod, "_is_interactive", lambda: False)
|
|
monkeypatch.setattr(
|
|
"webbrowser.open", MagicMock(side_effect=AssertionError("must not open browser"))
|
|
)
|
|
|
|
with pytest.raises(OAuthNonInteractiveError, match="browser authorization"):
|
|
asyncio.run(mod._make_redirect_handler(49300)("https://idp.example.com/authorize?x=1"))
|
|
|
|
err = capsys.readouterr().err
|
|
assert "https://idp.example.com/authorize" not in err
|
|
|
|
def test_guard_does_not_fire_on_interactive_redirect(self, monkeypatch, capsys):
|
|
"""Positive control: the fail-fast guard is scoped to the auth-code path.
|
|
|
|
#57836 regression coverage asks that valid/refreshable OAuth keeps
|
|
working non-interactively — a good token never reaches these handlers,
|
|
so the guard must be inert once a real flow is in progress. Assert the
|
|
interactive path still prints the URL and does not raise, proving the
|
|
guard does not over-fire and swallow legitimate authorization.
|
|
"""
|
|
import tools.mcp_oauth as mod
|
|
import asyncio
|
|
|
|
monkeypatch.setattr(mod, "_is_interactive", lambda: True)
|
|
# Local (non-SSH) interactive session with no browser available, so the
|
|
# handler falls through to the manual-URL print without opening a tab.
|
|
monkeypatch.delenv("SSH_CLIENT", raising=False)
|
|
monkeypatch.delenv("SSH_TTY", raising=False)
|
|
monkeypatch.setattr(mod, "_can_open_browser", lambda: False)
|
|
|
|
asyncio.run(mod._make_redirect_handler(49302)("https://idp.example.com/authorize?x=9"))
|
|
|
|
err = capsys.readouterr().err
|
|
assert "https://idp.example.com/authorize?x=9" in err
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Extracted helper tests (Task 3 of MCP OAuth consolidation)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PROXY_REDIRECT = "https://oauth.example.ts.net/callback"
|
|
|
|
|
|
@pytest.mark.parametrize("cfg, expected_auth", [
|
|
({"cimd": False}, "none"), # public client
|
|
({"client_secret": "shh"}, "client_secret_post"), # confidential client
|
|
])
|
|
def test_build_client_metadata_token_endpoint_auth(cfg, expected_auth):
|
|
pytest.importorskip("mcp")
|
|
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port
|
|
|
|
_configure_callback_port(cfg)
|
|
md = _build_client_metadata(cfg)
|
|
assert md.token_endpoint_auth_method == expected_auth
|
|
assert "authorization_code" in md.grant_types
|
|
assert "refresh_token" in md.grant_types
|
|
|
|
|
|
@pytest.mark.parametrize("cfg, expected", [
|
|
({"redirect_uri": _PROXY_REDIRECT}, _PROXY_REDIRECT),
|
|
({}, "http://127.0.0.1:1234/callback"),
|
|
# ``redirect_host: localhost`` swaps only the loopback hostname (WAF-safe)
|
|
({"redirect_host": "localhost"}, "http://localhost:1234/callback"),
|
|
])
|
|
def test_resolve_redirect_uri(cfg, expected):
|
|
from tools.mcp_oauth import _resolve_redirect_uri
|
|
|
|
assert _resolve_redirect_uri(cfg, 1234) == expected
|
|
|
|
|
|
def test_build_oauth_auth_preserves_server_url_path():
|
|
"""server_url with path is forwarded to OAuthClientProvider unmodified.
|
|
|
|
Regression for #16015: previously ``_parse_base_url`` stripped the path,
|
|
collapsing ``https://mcp.notion.com/mcp`` to ``https://mcp.notion.com`` and
|
|
breaking RFC 9728 protected-resource validation against servers whose PRM
|
|
advertises a path-scoped resource (Notion). The MCP SDK strips the path
|
|
itself for authorization-server discovery via
|
|
``OAuthContext.get_authorization_base_url``; Hermes must not pre-strip.
|
|
"""
|
|
from tools import mcp_oauth
|
|
|
|
captured: dict = {}
|
|
|
|
class _FakeProvider:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
with patch.object(mcp_oauth, "_OAUTH_AVAILABLE", True), \
|
|
patch.object(mcp_oauth, "HermesOAuthClientProvider", _FakeProvider), \
|
|
patch.object(mcp_oauth, "_is_interactive", return_value=True), \
|
|
patch.object(mcp_oauth, "_maybe_preregister_client"), \
|
|
patch.object(mcp_oauth, "HermesTokenStorage") as mock_storage_cls:
|
|
mock_storage_cls.return_value = MagicMock(has_cached_tokens=lambda: True)
|
|
build_oauth_auth(
|
|
server_name="notion",
|
|
server_url="https://mcp.notion.com/mcp",
|
|
oauth_config={},
|
|
)
|
|
|
|
assert captured["server_url"] == "https://mcp.notion.com/mcp"
|
|
|
|
|
|
class TestPasteCallbackReader:
|
|
"""_paste_callback_reader parses redirect URLs / query strings from stdin."""
|
|
|
|
def _empty_result(self):
|
|
return {"auth_code": None, "state": None, "error": None}
|
|
|
|
def test_parses_pasted_callback(self, monkeypatch):
|
|
result = self._empty_result()
|
|
pasted = "http://127.0.0.1:37949/callback?code=abc&state=xyz\n"
|
|
monkeypatch.setattr("sys.stdin", MagicMock(readline=lambda: pasted))
|
|
_paste_callback_reader(result)
|
|
assert result["auth_code"] == "abc"
|
|
assert result["state"] == "xyz"
|
|
assert result["error"] is None
|
|
|
|
|
|
def test_swallows_stdin_errors(self, monkeypatch):
|
|
"""OSError / interrupt on readline must not propagate."""
|
|
result = self._empty_result()
|
|
def raise_oserror():
|
|
raise OSError("stdin closed")
|
|
monkeypatch.setattr("sys.stdin", MagicMock(readline=raise_oserror))
|
|
_paste_callback_reader(result) # must not raise
|
|
assert result["auth_code"] is None
|
|
|
|
|
|
class TestWaitForCallbackPasteIntegration:
|
|
"""_wait_for_callback offers the paste prompt only when interactive."""
|
|
|
|
|
|
def test_paste_prompt_NOT_shown_when_interactivity_suppressed(self, monkeypatch):
|
|
"""Background MCP discovery must not race the CLI/TUI stdin reader."""
|
|
import tools.mcp_oauth as mod
|
|
|
|
mod._oauth_port = _find_free_port()
|
|
mock_stdin = MagicMock()
|
|
mock_stdin.isatty.return_value = True
|
|
monkeypatch.setattr(mod.sys, "stdin", mock_stdin)
|
|
|
|
async def instant_sleep(_):
|
|
pass
|
|
|
|
with patch.object(mod.asyncio, "sleep", instant_sleep):
|
|
with mod.suppress_interactive_oauth():
|
|
with pytest.raises(OAuthNonInteractiveError):
|
|
asyncio.run(_wait_for_callback())
|
|
mock_stdin.readline.assert_not_called()
|
|
|
|
|
|
class TestPasteCallbackSkipToken:
|
|
"""User can type `skip` (or similar) at the paste prompt to bail out."""
|
|
|
|
def _empty_result(self):
|
|
return {"auth_code": None, "state": None, "error": None}
|
|
|
|
@pytest.mark.parametrize("token", ["skip", "QUIT"])
|
|
def test_skip_tokens_set_sentinel(self, monkeypatch, token):
|
|
from tools.mcp_oauth import _USER_SKIPPED_SENTINEL
|
|
result = self._empty_result()
|
|
monkeypatch.setattr("sys.stdin", MagicMock(readline=lambda: token + "\n"))
|
|
_paste_callback_reader(result)
|
|
assert result["error"] == _USER_SKIPPED_SENTINEL
|
|
assert result["auth_code"] is None
|
|
|
|
def test_skip_does_not_overwrite_http_winner(self, monkeypatch):
|
|
"""If HTTP listener already wrote a code, `skip` must not stomp it."""
|
|
result = {"auth_code": "from_http", "state": "x", "error": None}
|
|
monkeypatch.setattr("sys.stdin", MagicMock(readline=lambda: "skip\n"))
|
|
_paste_callback_reader(result)
|
|
assert result["auth_code"] == "from_http"
|
|
assert result["error"] is None
|
|
|
|
|
|
class TestWaitForCallbackSkipIntegration:
|
|
"""_wait_for_callback maps the skip sentinel to OAuthNonInteractiveError."""
|
|
|
|
def test_skip_raises_non_interactive_error(self, monkeypatch):
|
|
"""Skip token must raise OAuthNonInteractiveError (mcp_tool handles as non-fatal)."""
|
|
import tools.mcp_oauth as mod
|
|
mod._oauth_port = _find_free_port()
|
|
monkeypatch.setattr(mod, "_is_interactive", lambda: True)
|
|
monkeypatch.setattr("sys.stdin", MagicMock(readline=lambda: "skip\n"))
|
|
|
|
async def instant_sleep(_):
|
|
pass
|
|
with patch.object(mod.asyncio, "sleep", instant_sleep):
|
|
with pytest.raises(OAuthNonInteractiveError, match="user_skipped"):
|
|
asyncio.run(_wait_for_callback())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# poison_client_registration (GH#36767)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPoisonClientRegistration:
|
|
def test_poison_backs_up_and_removes_client_and_meta(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
storage = HermesTokenStorage("srv")
|
|
d = tmp_path / "mcp-tokens"
|
|
d.mkdir(parents=True)
|
|
(d / "srv.json").write_text('{"access_token": "keep-me"}')
|
|
(d / "srv.client.json").write_text('{"client_id": "dead"}')
|
|
(d / "srv.meta.json").write_text('{"token_endpoint": "https://idp/token"}')
|
|
|
|
removed = storage.poison_client_registration()
|
|
|
|
assert removed is True
|
|
# Client + metadata gone, forcing re-registration on the next flow.
|
|
assert not (d / "srv.client.json").exists()
|
|
assert not (d / "srv.meta.json").exists()
|
|
# Backup of the client file kept for recovery.
|
|
assert (d / "srv.client.json.bak").read_text() == '{"client_id": "dead"}'
|
|
# Tokens are intentionally preserved.
|
|
assert (d / "srv.json").read_text() == '{"access_token": "keep-me"}'
|
|
|
|
|
|
|
|
|
|
def test_cancelled_waiter_releases_pinned_port_for_retry(monkeypatch):
|
|
"""A flow cancelled mid-wait (handshake timeout) must leave its pinned/cached port bindable: the
|
|
retry reuses the same port and previously died with EADDRINUSE because the listener thread parked
|
|
in select() kept the closed socket alive (#113771)."""
|
|
import socket
|
|
import tools.mcp_oauth as mo
|
|
|
|
monkeypatch.setattr(mo, "_is_interactive", lambda: False)
|
|
monkeypatch.setattr(mo, "_raise_if_non_interactive", lambda lead: None)
|
|
with socket.socket() as probe:
|
|
probe.bind(("127.0.0.1", 0))
|
|
port = probe.getsockname()[1]
|
|
waiter = mo._make_callback_waiter(port, timeout=30)
|
|
|
|
async def drive():
|
|
task = asyncio.create_task(waiter())
|
|
await asyncio.sleep(0.3) # listener bound and parked in its poll
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
with socket.socket() as again: # what the retry's _start_callback_server does
|
|
again.bind(("127.0.0.1", port))
|
|
|
|
asyncio.run(drive())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Figma remote MCP DCR allowlist workarounds
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_figma_provider_defaults_set_allowlisted_client_name():
|
|
from tools.mcp_oauth import (
|
|
apply_oauth_provider_defaults,
|
|
_FIGMA_DCR_CLIENT_NAME,
|
|
_FIGMA_DEFAULT_SCOPE,
|
|
)
|
|
|
|
cfg = apply_oauth_provider_defaults(
|
|
{},
|
|
server_name="figma",
|
|
server_url="https://mcp.figma.com/mcp",
|
|
)
|
|
assert cfg["client_name"] == _FIGMA_DCR_CLIENT_NAME
|
|
assert cfg["scope"] == _FIGMA_DEFAULT_SCOPE
|
|
|
|
|
|
def test_humanize_non_registration_403_passthrough():
|
|
from tools.mcp_oauth import humanize_oauth_registration_error
|
|
|
|
assert (
|
|
humanize_oauth_registration_error(
|
|
"linear",
|
|
RuntimeError("HTTP 403: insufficient_scope"),
|
|
server_url="https://mcp.linear.app/mcp",
|
|
)
|
|
is None
|
|
)
|