mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-16 05:02:21 +02:00
267 lines
9.2 KiB
Python
267 lines
9.2 KiB
Python
import asyncio
|
|
import json
|
|
from src import mcp_oauth
|
|
|
|
|
|
def test_registry_resolve_returns_code_and_state():
|
|
async def go():
|
|
fut = mcp_oauth.register_pending("st-1")
|
|
assert mcp_oauth.resolve_pending("st-1", "the-code") is True
|
|
return await asyncio.wait_for(fut, timeout=1)
|
|
code, state = asyncio.run(go())
|
|
assert code == "the-code"
|
|
assert state == "st-1"
|
|
|
|
|
|
def test_resolve_unknown_state_is_false():
|
|
assert mcp_oauth.resolve_pending("nope", "x") is False
|
|
|
|
|
|
def test_register_pending_prunes_abandoned_flows():
|
|
import time as _t
|
|
|
|
async def go():
|
|
mcp_oauth._pending.clear()
|
|
mcp_oauth._pending_ts.clear()
|
|
old = mcp_oauth.register_pending("old-state")
|
|
# Backdate the entry past the authorization window.
|
|
mcp_oauth._pending_ts["old-state"] = _t.monotonic() - (mcp_oauth.AUTH_WAIT_SECONDS + 1)
|
|
# A new registration triggers a prune of the stale one.
|
|
mcp_oauth.register_pending("new-state")
|
|
return old
|
|
|
|
old = asyncio.run(go())
|
|
assert "old-state" not in mcp_oauth._pending
|
|
assert "old-state" not in mcp_oauth._pending_ts
|
|
assert "new-state" in mcp_oauth._pending
|
|
assert old.cancelled()
|
|
|
|
|
|
def test_build_provider_has_odysseus_client_metadata():
|
|
p = mcp_oauth.build_provider("srv-1", "https://example.com/mcp")
|
|
md = p.context.client_metadata
|
|
assert md.client_name == "Odysseus"
|
|
assert "authorization_code" in md.grant_types
|
|
assert "refresh_token" in md.grant_types
|
|
assert str(md.redirect_uris[0]).rstrip("/") == mcp_oauth.REDIRECT_URI.rstrip("/")
|
|
|
|
|
|
def test_db_token_storage_round_trip():
|
|
from mcp.shared.auth import OAuthToken
|
|
|
|
class FakeSrv:
|
|
oauth_tokens = None
|
|
|
|
srv = FakeSrv()
|
|
|
|
class FakeQuery:
|
|
def filter(self, *a):
|
|
return self
|
|
|
|
def first(self):
|
|
return srv
|
|
|
|
class FakeSession:
|
|
def query(self, *a):
|
|
return FakeQuery()
|
|
|
|
def commit(self):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
storage = mcp_oauth.DbTokenStorage("srv-1", session_factory=lambda: FakeSession())
|
|
|
|
async def go():
|
|
await storage.set_tokens(OAuthToken(access_token="abc", token_type="Bearer"))
|
|
return await storage.get_tokens()
|
|
|
|
t = asyncio.run(go())
|
|
assert t.access_token == "abc"
|
|
assert srv.oauth_tokens is not None # persisted as JSON
|
|
|
|
|
|
def _fake_storage(oauth_tokens):
|
|
class FakeSrv:
|
|
pass
|
|
|
|
srv = FakeSrv()
|
|
srv.oauth_tokens = oauth_tokens
|
|
|
|
class FakeQuery:
|
|
def filter(self, *a):
|
|
return self
|
|
|
|
def first(self):
|
|
return srv
|
|
|
|
class FakeSession:
|
|
def query(self, *a):
|
|
return FakeQuery()
|
|
|
|
def commit(self):
|
|
pass
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
return srv, mcp_oauth.DbTokenStorage("srv-1", session_factory=lambda: FakeSession())
|
|
|
|
|
|
def test_load_falls_back_to_empty_dict_for_non_dict_json():
|
|
# A corrupted/migrated oauth_tokens column holding a JSON array, not an
|
|
# object, must not crash _load()'s callers with AttributeError.
|
|
_srv, storage = _fake_storage('["stale", "data"]')
|
|
assert storage._load() == {}
|
|
|
|
|
|
def test_get_tokens_returns_none_for_non_dict_oauth_tokens():
|
|
_srv, storage = _fake_storage("42")
|
|
|
|
async def go():
|
|
return await storage.get_tokens()
|
|
|
|
assert asyncio.run(go()) is None
|
|
|
|
|
|
def test_update_recovers_from_non_dict_oauth_tokens():
|
|
# _update() must not raise TypeError trying to item-assign into a list.
|
|
srv, storage = _fake_storage('["stale", "data"]')
|
|
storage._update("tokens", {"access_token": "new"})
|
|
assert json.loads(srv.oauth_tokens) == {"tokens": {"access_token": "new"}}
|
|
|
|
|
|
# ── Callback origin ───────────────────────────────────────────────
|
|
#
|
|
# The redirect URI is registered with the authorization server (dynamically for
|
|
# remote MCP servers, by hand for Google ones) and the browser is sent to it
|
|
# after authorizing. It is resolved once, outside any request, so it cannot be
|
|
# derived from the request the way the email OAuth routes derive theirs — an
|
|
# operator-supplied origin is the only thing that can be right behind a proxy.
|
|
# What the default can get right is the port, which the app knows.
|
|
|
|
_REDIRECT_ENV = ("OAUTH_REDIRECT_BASE_URL", "APP_PUBLIC_URL", "APP_PORT")
|
|
|
|
|
|
def _resolve_base(monkeypatch, **env):
|
|
for key in _REDIRECT_ENV:
|
|
monkeypatch.delenv(key, raising=False)
|
|
for key, value in env.items():
|
|
monkeypatch.setenv(key, value)
|
|
return mcp_oauth._resolve_redirect_base()
|
|
|
|
|
|
def test_redirect_base_defaults_to_the_bound_port(monkeypatch):
|
|
# The macOS launcher serves on 7860 because AirPlay Receiver holds 7000; a
|
|
# callback pinned to 7000 lands on AirPlay instead of Odysseus.
|
|
assert _resolve_base(monkeypatch, APP_PORT="7860") == "http://localhost:7860"
|
|
|
|
|
|
def test_redirect_base_keeps_7000_when_app_port_is_unset(monkeypatch):
|
|
assert _resolve_base(monkeypatch) == "http://localhost:7000"
|
|
|
|
|
|
def test_redirect_base_prefers_the_explicit_origin(monkeypatch):
|
|
# Only an operator-supplied origin can be right behind a TLS proxy, so it
|
|
# outranks the derived default — and its trailing slash is trimmed.
|
|
resolved = _resolve_base(
|
|
monkeypatch, OAUTH_REDIRECT_BASE_URL="https://odysseus.example/", APP_PORT="7860"
|
|
)
|
|
assert resolved == "https://odysseus.example"
|
|
|
|
|
|
def test_redirect_base_accepts_app_public_url_as_the_alias(monkeypatch):
|
|
assert (
|
|
_resolve_base(monkeypatch, APP_PUBLIC_URL="https://public.example", APP_PORT="7860")
|
|
== "https://public.example"
|
|
)
|
|
|
|
|
|
# ── Paste-back form origin ────────────────────────────────────────
|
|
|
|
def _authorize_page():
|
|
from routes.mcp.mcp_routes import _oauth_authorize_page
|
|
|
|
return _oauth_authorize_page(
|
|
"https://accounts.google.com/o/oauth2/v2/auth?state=srv-1",
|
|
"srv-1",
|
|
"https://odysseus.example.com/api/mcp/oauth/callback",
|
|
)
|
|
|
|
|
|
def test_paste_back_form_action_is_relative():
|
|
# Remote users finish the flow by pasting the callback URL into this form,
|
|
# so it has to post back to the origin they are on. An absolute action
|
|
# cannot: an http:// one is mixed content on an HTTPS page and gets blocked,
|
|
# and the app cannot reliably tell that it is behind TLS, because uvicorn
|
|
# only honours X-Forwarded-Proto from a peer inside --forwarded-allow-ips
|
|
# (default 127.0.0.1, which a proxy on the Docker bridge is not). A relative
|
|
# action is resolved by the browser and is right in every one of those cases.
|
|
page = _authorize_page()
|
|
assert 'action="/api/mcp/oauth/exchange/srv-1"' in page
|
|
assert 'action="http' not in page
|
|
|
|
|
|
# ── Docker configurability ────────────────────────────────────────
|
|
#
|
|
# The override above is the only fix available to a Docker install: the
|
|
# container always listens on 7000 and cannot see the host port map, so the
|
|
# derived default cannot be right there. Compose has to forward the variable
|
|
# or the escape hatch does not exist.
|
|
|
|
_COMPOSE_FILES = (
|
|
"docker-compose.yml",
|
|
"docker-compose.gpu-nvidia.yml",
|
|
"docker-compose.gpu-amd.yml",
|
|
)
|
|
|
|
|
|
def _repo_root():
|
|
from pathlib import Path
|
|
|
|
return Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def test_redirect_base_override_is_forwarded_into_the_container():
|
|
import yaml
|
|
|
|
for name in _COMPOSE_FILES:
|
|
path = _repo_root() / name
|
|
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
environment = set(compose["services"]["odysseus"]["environment"])
|
|
assert "OAUTH_REDIRECT_BASE_URL=${OAUTH_REDIRECT_BASE_URL:-}" in environment, name
|
|
|
|
|
|
def test_redirect_base_override_is_documented():
|
|
import pytest
|
|
|
|
env_example = _repo_root() / ".env.example"
|
|
if not env_example.exists():
|
|
pytest.skip("this checkout does not include the optional .env.example file")
|
|
assert "# OAUTH_REDIRECT_BASE_URL=" in env_example.read_text(encoding="utf-8")
|
|
|
|
|
|
# ── Launcher port propagation ─────────────────────────────────────
|
|
#
|
|
# The derived default is only as good as APP_PORT, and every launcher hands the
|
|
# port to uvicorn as a command-line flag, which the app cannot read back. Each
|
|
# one has to put the same value in the environment or the callback falls back to
|
|
# 7000 — which is the macOS-launcher-on-7860 case this whole change is about.
|
|
# internal_api_base() and companion pairing read APP_PORT too, so they go wrong
|
|
# in the same way.
|
|
|
|
_LAUNCHERS = (
|
|
# file, the export, the uvicorn flag it has to agree with
|
|
("start-macos.sh", 'export APP_PORT="$PORT"', '--port "$PORT"'),
|
|
("build-macos-app.sh", 'export APP_PORT="$PORT"', '--port "$PORT"'),
|
|
("launch-windows.ps1", "$env:APP_PORT = $Port", "--port $Port"),
|
|
)
|
|
|
|
|
|
def test_launchers_export_the_port_they_serve_on():
|
|
for name, export, uvicorn_flag in _LAUNCHERS:
|
|
text = (_repo_root() / name).read_text(encoding="utf-8")
|
|
assert uvicorn_flag in text, f"{name}: launcher no longer passes {uvicorn_flag}"
|
|
assert export in text, f"{name}: serves on a port the app cannot read back"
|