Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com> Co-authored-by: Hdandria <henri.dandria@mistral.ai> Co-authored-by: Ivana Dunisijevic <ivana.dunisijevic@mistral.ai> Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com> Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai> Co-authored-by: Mert Unsal <mert.unsal@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Val <102326092+vdeva@users.noreply.github.com> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
553 lines
19 KiB
Python
553 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Callable, Iterator
|
|
from contextlib import suppress
|
|
import socket
|
|
from types import TracebackType
|
|
from unittest.mock import patch
|
|
import urllib.parse
|
|
|
|
import httpx
|
|
import keyring
|
|
from keyring.backend import KeyringBackend
|
|
import keyring.backends.fail
|
|
import keyring.errors
|
|
from mcp.client.auth import OAuthFlowError
|
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
|
import pytest
|
|
import respx
|
|
|
|
from vibe.core.auth.mcp_oauth import (
|
|
Fingerprint,
|
|
KeyringTokenStorage,
|
|
LoopbackCallbackHandler,
|
|
MCPOAuthError,
|
|
MCPOAuthHeadlessError,
|
|
MCPOAuthLoginFailed,
|
|
MCPOAuthPortInUse,
|
|
build_oauth_provider,
|
|
perform_oauth_login,
|
|
)
|
|
from vibe.core.config import MCPOAuth, MCPStreamableHttp
|
|
|
|
|
|
class _MemoryKeyring(KeyringBackend):
|
|
priority = 100 # type: ignore[assignment]
|
|
|
|
def __init__(self) -> None:
|
|
self.store: dict[tuple[str, str], str] = {}
|
|
|
|
def get_password(self, service: str, username: str) -> str | None:
|
|
return self.store.get((service, username))
|
|
|
|
def set_password(self, service: str, username: str, password: str) -> None:
|
|
self.store[(service, username)] = password
|
|
|
|
def delete_password(self, service: str, username: str) -> None:
|
|
if (service, username) not in self.store:
|
|
raise keyring.errors.PasswordDeleteError(username)
|
|
del self.store[(service, username)]
|
|
|
|
|
|
@pytest.fixture
|
|
def memory_keyring() -> Iterator[_MemoryKeyring]:
|
|
original = keyring.get_keyring()
|
|
fake = _MemoryKeyring()
|
|
keyring.set_keyring(fake)
|
|
try:
|
|
yield fake
|
|
finally:
|
|
keyring.set_keyring(original)
|
|
|
|
|
|
@pytest.fixture
|
|
def headless_keyring() -> Iterator[None]:
|
|
original = keyring.get_keyring()
|
|
keyring.set_keyring(keyring.backends.fail.Keyring())
|
|
try:
|
|
yield
|
|
finally:
|
|
keyring.set_keyring(original)
|
|
|
|
|
|
def _oauth_server(
|
|
*,
|
|
name: str = "demo",
|
|
url: str = "https://mcp.example.com/mcp",
|
|
scopes: list[str] | None = None,
|
|
client_id: str | None = None,
|
|
client_metadata_url: str | None = None,
|
|
redirect_port: int = 47823,
|
|
) -> MCPStreamableHttp:
|
|
auth = MCPOAuth(
|
|
type="oauth",
|
|
scopes=scopes if scopes is not None else ["read", "write"],
|
|
client_id=client_id,
|
|
client_metadata_url=client_metadata_url, # type: ignore[arg-type]
|
|
redirect_port=redirect_port,
|
|
)
|
|
return MCPStreamableHttp(transport="streamable-http", name=name, url=url, auth=auth)
|
|
|
|
|
|
def _free_port() -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
s.bind(("127.0.0.1", 0))
|
|
return int(s.getsockname()[1])
|
|
|
|
|
|
async def _send_callback(port: int, query: str, *, timeout: float = 5.0) -> bytes:
|
|
deadline = asyncio.get_event_loop().time() + timeout
|
|
last_err: BaseException | None = None
|
|
while asyncio.get_event_loop().time() < deadline:
|
|
try:
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", port)
|
|
except (ConnectionRefusedError, OSError) as exc:
|
|
last_err = exc
|
|
await asyncio.sleep(0.02)
|
|
continue
|
|
try:
|
|
request = (
|
|
f"GET /callback?{query} HTTP/1.0\r\n"
|
|
"Host: 127.0.0.1\r\n"
|
|
"Connection: close\r\n"
|
|
"\r\n"
|
|
)
|
|
writer.write(request.encode("ascii"))
|
|
await writer.drain()
|
|
return await reader.read()
|
|
finally:
|
|
writer.close()
|
|
with suppress(Exception):
|
|
await writer.wait_closed()
|
|
raise RuntimeError(f"loopback never bound on port {port}: {last_err}")
|
|
|
|
|
|
class TestKeyringTokenStorage:
|
|
@pytest.mark.asyncio
|
|
async def test_round_trip_tokens(self, memory_keyring: _MemoryKeyring) -> None:
|
|
storage = KeyringTokenStorage(alias="linear")
|
|
assert await storage.get_tokens() is None
|
|
|
|
tokens = OAuthToken(
|
|
access_token="at",
|
|
token_type="Bearer",
|
|
expires_in=3600,
|
|
refresh_token="rt",
|
|
scope="read write",
|
|
)
|
|
await storage.set_tokens(tokens)
|
|
|
|
loaded = await storage.get_tokens()
|
|
assert loaded is not None
|
|
assert loaded.access_token == "at"
|
|
assert loaded.refresh_token == "rt"
|
|
assert loaded.scope == "read write"
|
|
assert ("vibe", "mcp-oauth:linear:tokens") in memory_keyring.store
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_round_trip_client_info(self, memory_keyring: _MemoryKeyring) -> None:
|
|
storage = KeyringTokenStorage(alias="linear")
|
|
assert await storage.get_client_info() is None
|
|
|
|
info = OAuthClientInformationFull(
|
|
client_id="abc123",
|
|
redirect_uris=["http://127.0.0.1:47823/callback"], # type: ignore[list-item]
|
|
token_endpoint_auth_method="none",
|
|
)
|
|
await storage.set_client_info(info)
|
|
|
|
loaded = await storage.get_client_info()
|
|
assert loaded is not None
|
|
assert loaded.client_id == "abc123"
|
|
assert ("vibe", "mcp-oauth:linear:client_info") in memory_keyring.store
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_per_alias_isolation(self, memory_keyring: _MemoryKeyring) -> None:
|
|
a = KeyringTokenStorage(alias="linear")
|
|
b = KeyringTokenStorage(alias="notion")
|
|
await a.set_tokens(
|
|
OAuthToken(access_token="A", token_type="Bearer", expires_in=60)
|
|
)
|
|
await b.set_tokens(
|
|
OAuthToken(access_token="B", token_type="Bearer", expires_in=60)
|
|
)
|
|
loaded_a = await a.get_tokens()
|
|
loaded_b = await b.get_tokens()
|
|
assert loaded_a is not None and loaded_a.access_token == "A"
|
|
assert loaded_b is not None and loaded_b.access_token == "B"
|
|
|
|
def test_headless_init_raises(self, headless_keyring: None) -> None:
|
|
with pytest.raises(MCPOAuthHeadlessError) as exc_info:
|
|
KeyringTokenStorage(alias="linear")
|
|
msg = str(exc_info.value)
|
|
assert "linear" in msg
|
|
assert "api_key_env" in msg
|
|
assert exc_info.value.server_alias == "linear"
|
|
|
|
|
|
class TestFingerprint:
|
|
def test_compute_stable_across_scope_order(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
a = _oauth_server(scopes=["read", "write", "admin"])
|
|
b = _oauth_server(scopes=["admin", "write", "read"])
|
|
assert Fingerprint.compute(a) == Fingerprint.compute(b)
|
|
|
|
def test_compute_strips_whitespace_and_dedupes(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
a = _oauth_server(scopes=["read", "write"])
|
|
b = _oauth_server(scopes=[" read ", "write", "write", ""])
|
|
assert Fingerprint.compute(a) == Fingerprint.compute(b)
|
|
|
|
def test_compute_marker_for_client_id(self, memory_keyring: _MemoryKeyring) -> None:
|
|
srv = _oauth_server(client_id="pre-registered-id")
|
|
assert Fingerprint.compute(srv).client_marker == "pre-registered-id"
|
|
|
|
def test_compute_marker_for_client_metadata_url(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
srv = _oauth_server(client_metadata_url="https://vibe.example/cm.json")
|
|
fp = Fingerprint.compute(srv)
|
|
assert fp.client_marker.startswith("https://vibe.example/cm.json")
|
|
|
|
def test_compute_marker_for_dcr(self, memory_keyring: _MemoryKeyring) -> None:
|
|
assert Fingerprint.compute(_oauth_server()).client_marker == "<dcr>"
|
|
|
|
def test_compute_rejects_static_auth(self, memory_keyring: _MemoryKeyring) -> None:
|
|
from vibe.core.config import MCPStaticAuth
|
|
|
|
srv = MCPStreamableHttp(
|
|
transport="streamable-http",
|
|
name="x",
|
|
url="https://x/mcp",
|
|
auth=MCPStaticAuth(),
|
|
)
|
|
with pytest.raises(TypeError, match="OAuth"):
|
|
Fingerprint.compute(srv)
|
|
|
|
def test_matches_detects_url_change(self, memory_keyring: _MemoryKeyring) -> None:
|
|
a = Fingerprint.compute(_oauth_server(url="https://a/mcp"))
|
|
b = Fingerprint.compute(_oauth_server(url="https://b/mcp"))
|
|
assert a != b
|
|
|
|
def test_matches_detects_scope_change(self, memory_keyring: _MemoryKeyring) -> None:
|
|
a = Fingerprint.compute(_oauth_server(scopes=["read"]))
|
|
b = Fingerprint.compute(_oauth_server(scopes=["read", "write"]))
|
|
assert a != b
|
|
|
|
def test_matches_detects_marker_change(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
a = Fingerprint.compute(_oauth_server(client_id="x"))
|
|
b = Fingerprint.compute(_oauth_server(client_id="y"))
|
|
assert a != b
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_load_returns_none_when_missing(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
assert await Fingerprint.load("nope") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_and_load_round_trip(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
fp = Fingerprint.compute(_oauth_server(name="linear"))
|
|
await fp.save("linear")
|
|
loaded = await Fingerprint.load("linear")
|
|
assert loaded == fp
|
|
|
|
|
|
class TestLoopbackCallbackHandler:
|
|
@pytest.mark.asyncio
|
|
async def test_happy_path(self) -> None:
|
|
port = _free_port()
|
|
handler = LoopbackCallbackHandler(port=port, server_alias="demo")
|
|
|
|
async def driver() -> bytes:
|
|
return await _send_callback(port, "code=AUTH_CODE_123&state=STATE_XYZ")
|
|
|
|
serve_task = asyncio.create_task(handler.serve_once())
|
|
driver_task = asyncio.create_task(driver())
|
|
code, state = await serve_task
|
|
response = await driver_task
|
|
|
|
assert code == "AUTH_CODE_123"
|
|
assert state == "STATE_XYZ"
|
|
assert b"200 OK" in response
|
|
assert b"Login complete" in response
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_port_in_use_raises(self) -> None:
|
|
port = _free_port()
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
|
sock.bind(("127.0.0.1", port))
|
|
sock.listen(1)
|
|
try:
|
|
handler = LoopbackCallbackHandler(port=port, server_alias="demo")
|
|
with pytest.raises(MCPOAuthPortInUse) as exc_info:
|
|
await handler.serve_once()
|
|
assert exc_info.value.port == port
|
|
assert exc_info.value.server_alias == "demo"
|
|
assert "redirect_port" in str(exc_info.value)
|
|
finally:
|
|
sock.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_code_raises(self) -> None:
|
|
port = _free_port()
|
|
handler = LoopbackCallbackHandler(port=port, server_alias="demo")
|
|
|
|
async def driver() -> bytes:
|
|
return await _send_callback(port, "error=access_denied&state=S")
|
|
|
|
serve_task = asyncio.create_task(handler.serve_once())
|
|
driver_task = asyncio.create_task(driver())
|
|
with pytest.raises(MCPOAuthError, match="missing 'code'"):
|
|
await serve_task
|
|
response = await driver_task
|
|
assert b"400 Bad Request" in response
|
|
|
|
|
|
class TestBuildOAuthProvider:
|
|
@pytest.mark.asyncio
|
|
async def test_metadata_matches_config(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
srv = _oauth_server(scopes=["read", "write"], redirect_port=51234)
|
|
|
|
async def on_url(_url: str) -> None:
|
|
return None
|
|
|
|
async def cb() -> tuple[str, str | None]:
|
|
return "code", None
|
|
|
|
provider = build_oauth_provider(
|
|
srv, redirect_handler=on_url, callback_handler=cb
|
|
)
|
|
md = provider.context.client_metadata
|
|
assert md.scope == "read write"
|
|
assert md.client_name == "Mistral Vibe"
|
|
assert md.token_endpoint_auth_method == "none"
|
|
assert md.grant_types == ["authorization_code", "refresh_token"]
|
|
assert md.redirect_uris is not None
|
|
assert str(md.redirect_uris[0]) == "http://127.0.0.1:51234/callback"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_scopes_becomes_none(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
srv = _oauth_server(scopes=[])
|
|
|
|
async def on_url(_url: str) -> None:
|
|
return None
|
|
|
|
async def cb() -> tuple[str, str | None]:
|
|
return "code", None
|
|
|
|
provider = build_oauth_provider(
|
|
srv, redirect_handler=on_url, callback_handler=cb
|
|
)
|
|
assert provider.context.client_metadata.scope is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_metadata_url_forwarded(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
srv = _oauth_server(client_metadata_url="https://vibe.example/cm.json")
|
|
|
|
async def on_url(_url: str) -> None:
|
|
return None
|
|
|
|
async def cb() -> tuple[str, str | None]:
|
|
return "code", None
|
|
|
|
provider = build_oauth_provider(
|
|
srv, redirect_handler=on_url, callback_handler=cb
|
|
)
|
|
assert provider.context.client_metadata_url == "https://vibe.example/cm.json"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_id_is_exposed_as_fallback_client_info(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
srv = _oauth_server(client_id="pre-registered-client")
|
|
|
|
async def on_url(_url: str) -> None:
|
|
return None
|
|
|
|
async def cb() -> tuple[str, str | None]:
|
|
return "code", None
|
|
|
|
provider = build_oauth_provider(
|
|
srv, redirect_handler=on_url, callback_handler=cb
|
|
)
|
|
client_info = await provider.context.storage.get_client_info()
|
|
|
|
assert client_info is not None
|
|
assert client_info.client_id == "pre-registered-client"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rejects_static_auth(self, memory_keyring: _MemoryKeyring) -> None:
|
|
from vibe.core.config import MCPStaticAuth
|
|
|
|
srv = MCPStreamableHttp(
|
|
transport="streamable-http",
|
|
name="x",
|
|
url="https://x/mcp",
|
|
auth=MCPStaticAuth(),
|
|
)
|
|
|
|
async def on_url(_url: str) -> None:
|
|
return None
|
|
|
|
async def cb() -> tuple[str, str | None]:
|
|
return "code", None
|
|
|
|
with pytest.raises(TypeError, match="OAuth"):
|
|
build_oauth_provider(srv, redirect_handler=on_url, callback_handler=cb)
|
|
|
|
|
|
class TestPerformOAuthLogin:
|
|
@pytest.mark.asyncio
|
|
async def test_oauth_flow_error_becomes_login_failed(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
srv = _oauth_server(name="demo")
|
|
|
|
class OAuthFlowFailingClient:
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> OAuthFlowFailingClient:
|
|
return self
|
|
|
|
async def __aexit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc: BaseException | None,
|
|
traceback: TracebackType | None,
|
|
) -> None:
|
|
pass
|
|
|
|
async def get(self, _url: str) -> None:
|
|
raise OAuthFlowError("cancelled")
|
|
|
|
async def on_url(_url: str) -> None:
|
|
pass
|
|
|
|
with patch(
|
|
"vibe.core.auth.mcp_oauth.httpx.AsyncClient", new=OAuthFlowFailingClient
|
|
):
|
|
with pytest.raises(MCPOAuthLoginFailed, match="cancelled"):
|
|
await perform_oauth_login(srv, on_url=on_url)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_flow_persists_tokens_and_fingerprint(
|
|
self, memory_keyring: _MemoryKeyring
|
|
) -> None:
|
|
port = _free_port()
|
|
server_url = "https://mcp.example.com/mcp"
|
|
as_url = "https://as.example.com"
|
|
srv = _oauth_server(
|
|
name="demo", url=server_url, scopes=["read"], redirect_port=port
|
|
)
|
|
|
|
async def on_url(url: str) -> None:
|
|
qs = urllib.parse.urlparse(url).query
|
|
state = urllib.parse.parse_qs(qs)["state"][0]
|
|
|
|
async def fire() -> None:
|
|
await _send_callback(port, f"code=THE_CODE&state={state}")
|
|
|
|
asyncio.get_event_loop().create_task(fire())
|
|
|
|
async with respx.mock(assert_all_called=False) as router:
|
|
router.get(server_url).mock(side_effect=_mcp_responses())
|
|
router.get(
|
|
"https://mcp.example.com/.well-known/oauth-protected-resource"
|
|
).mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"resource": server_url, "authorization_servers": [as_url]},
|
|
)
|
|
)
|
|
router.get(
|
|
"https://as.example.com/.well-known/oauth-authorization-server"
|
|
).mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"issuer": as_url,
|
|
"authorization_endpoint": f"{as_url}/authorize",
|
|
"token_endpoint": f"{as_url}/token",
|
|
"registration_endpoint": f"{as_url}/register",
|
|
"response_types_supported": ["code"],
|
|
"code_challenge_methods_supported": ["S256"],
|
|
"grant_types_supported": [
|
|
"authorization_code",
|
|
"refresh_token",
|
|
],
|
|
},
|
|
)
|
|
)
|
|
router.post(f"{as_url}/register").mock(
|
|
return_value=httpx.Response(
|
|
201,
|
|
json={
|
|
"client_id": "dcr-client-id",
|
|
"redirect_uris": [f"http://127.0.0.1:{port}/callback"],
|
|
"token_endpoint_auth_method": "none",
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
},
|
|
)
|
|
)
|
|
router.post(f"{as_url}/token").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"access_token": "ACCESS_TOKEN",
|
|
"token_type": "Bearer",
|
|
"expires_in": 3600,
|
|
"refresh_token": "REFRESH_TOKEN",
|
|
"scope": "read",
|
|
},
|
|
)
|
|
)
|
|
router.route(host="127.0.0.1").pass_through()
|
|
|
|
await perform_oauth_login(srv, on_url=on_url)
|
|
|
|
storage = KeyringTokenStorage(alias="demo")
|
|
tokens = await storage.get_tokens()
|
|
assert tokens is not None
|
|
assert tokens.access_token == "ACCESS_TOKEN"
|
|
assert tokens.refresh_token == "REFRESH_TOKEN"
|
|
|
|
fp = await Fingerprint.load("demo")
|
|
assert fp is not None
|
|
assert fp == Fingerprint.compute(srv)
|
|
|
|
|
|
def _mcp_responses() -> Callable[[httpx.Request], httpx.Response]:
|
|
state = {"calls": 0}
|
|
|
|
def _factory(_request: httpx.Request) -> httpx.Response:
|
|
state["calls"] += 1
|
|
if state["calls"] == 1:
|
|
return httpx.Response(
|
|
401,
|
|
headers={
|
|
"WWW-Authenticate": (
|
|
"Bearer resource_metadata="
|
|
'"https://mcp.example.com/.well-known/oauth-protected-resource"'
|
|
)
|
|
},
|
|
)
|
|
return httpx.Response(200, json={"ok": True})
|
|
|
|
return _factory
|