from __future__ import annotations import base64 import hashlib import time from unittest.mock import patch import pytest from app.config import get_settings from app.crypto import encrypt_token CURSOR_WEB = "https://www.cursor.com/agents/mcp/oauth/callback" LOOPBACK = "http://localhost:8787/callback" REG_BODY = { "client_name": "Cursor", "redirect_uris": [CURSOR_WEB, LOOPBACK], "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], } def _pkce(): verifier = "dcr-verifier-string-long-enough-1234567890abc" challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() return verifier, challenge def _authorize(client, client_id, redirect_uri, challenge, state="st"): return client.get( "/authorize", params={ "response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "code_challenge": challenge, "code_challenge_method": "S256", "state": state, }, follow_redirects=False, ) def test_metadata_advertises_registration(client): meta = client.get("/.well-known/oauth-authorization-server").json() assert meta["registration_endpoint"] == "http://testserver/register" pr = client.get("/.well-known/oauth-protected-resource/mcp").json() assert pr["authorization_servers"] == ["http://testserver"] def test_register_returns_rfc7591_response(client): resp = client.post("/register", json=REG_BODY) assert resp.status_code == 201 body = resp.json() assert body["client_id"].startswith("dcr-") assert body["redirect_uris"] == [CURSOR_WEB, LOOPBACK] assert body["token_endpoint_auth_method"] == "none" assert body["registration_access_token"] assert "client_secret" not in body @pytest.mark.parametrize( "uri", ["http://evil.example/cb", "javascript:alert(1)", "https://x.example/cb#frag", "", "https://*.example/cb"], ) def test_register_rejects_bad_redirect(client, uri): resp = client.post("/register", json={"redirect_uris": [uri]}) assert resp.status_code == 400 assert resp.json()["error"] == "invalid_redirect_uri" def test_register_accepts_app_scheme(client): resp = client.post("/register", json={"redirect_uris": ["cursor://anysphere.cursor-mcp/oauth/callback"]}) assert resp.status_code == 201 def test_register_rejects_non_json(client): resp = client.post("/register", content=b"nope", headers={"content-type": "application/json"}) assert resp.status_code == 400 def test_unauthenticated_authorize_goes_to_login(client): cid = client.post("/register", json=REG_BODY).json()["client_id"] _, challenge = _pkce() resp = _authorize(client, cid, CURSOR_WEB, challenge) assert resp.status_code == 302 assert resp.headers["location"].startswith("/oidc/login?") def test_unregistered_redirect_rejected(client): cid = client.post("/register", json=REG_BODY).json()["client_id"] _, challenge = _pkce() assert _authorize(client, cid, "https://other.example/cb", challenge).status_code == 400 def test_full_code_flow_with_dynamic_client(client, database, monkeypatch): settings = get_settings() user = database.upsert_user("sub-dcr", "d@example.com") database.set_upstream_tokens(user.id, encrypt_token(settings.token_encryption_key, "gitea_x"), None, time.time() + 3600) import app.main as main monkeypatch.setattr(main, "session_user_id", lambda request: user.id) cid = client.post("/register", json=REG_BODY).json()["client_id"] verifier, challenge = _pkce() resp = _authorize(client, cid, LOOPBACK, challenge) assert resp.status_code == 302 code = resp.headers["location"].split("code=")[1].split("&")[0] tok = client.post( "/token", data={"grant_type": "authorization_code", "code": code, "redirect_uri": LOOPBACK, "client_id": cid, "code_verifier": verifier}, ) assert tok.status_code == 200 assert tok.json()["access_token"] bad = client.post( "/token", data={"grant_type": "authorization_code", "code": code, "redirect_uri": LOOPBACK, "client_id": cid, "code_verifier": verifier}, ) assert bad.status_code == 400 def test_registration_survives_new_database_instance(client): cid = client.post("/register", json=REG_BODY).json()["client_id"] import app.main as main from app.db import Database fresh = Database(get_settings().data_dir) assert fresh.get_client_redirect_uris(cid) == [CURSOR_WEB, LOOPBACK] main._db = None _, challenge = _pkce() assert _authorize(client, cid, CURSOR_WEB, challenge).status_code == 302 def test_rfc7592_read_and_delete(client): body = client.post("/register", json=REG_BODY).json() cid, tok = body["client_id"], body["registration_access_token"] assert client.get(f"/register/{cid}").status_code == 401 assert client.delete(f"/register/{cid}", headers={"Authorization": "Bearer wrong"}).status_code == 401 got = client.get(f"/register/{cid}", headers={"Authorization": f"Bearer {tok}"}) assert got.status_code == 200 and got.json()["client_id"] == cid assert client.delete(f"/register/{cid}", headers={"Authorization": f"Bearer {tok}"}).status_code == 204 _, challenge = _pkce() assert _authorize(client, cid, CURSOR_WEB, challenge).status_code == 400 def test_static_client_still_accepted(client): _, challenge = _pkce() resp = _authorize(client, "cursor", LOOPBACK, challenge) assert resp.status_code == 302 assert resp.headers["location"].startswith("/oidc/login?") # ---- proxying to gitea-mcp ---- class FakeResp: def __init__(self, status=200, json_body=None, content=b'{"ok":true}', headers=None): self.status_code = status self._json = json_body or {} self.content = content self.headers = headers or {"content-type": "application/json"} def json(self): return self._json class FakeAsyncClient: calls: list = [] refresh_response = FakeResp(200, {"access_token": "gitea_new", "refresh_token": "r2", "expires_in": 3600}) upstream_response = FakeResp(headers={"content-type": "application/json", "mcp-session-id": "sess-1"}) def __init__(self, *a, **k): pass async def __aenter__(self): return self async def __aexit__(self, *a): return False async def post(self, url, data=None): FakeAsyncClient.calls.append(("post", url, data)) return FakeAsyncClient.refresh_response async def request(self, method, url, content=None, headers=None): FakeAsyncClient.calls.append(("request", url, headers)) return FakeAsyncClient.upstream_response @pytest.fixture def proxy(client, monkeypatch): monkeypatch.setenv("UPSTREAM_MCP_URL", "http://gitea-mcp:8080/mcp") get_settings.cache_clear() FakeAsyncClient.calls = [] yield client get_settings.cache_clear() def test_proxy_uses_upstream_token(proxy, database): settings = get_settings() user = database.upsert_user("gitea-1", "g@example.com") database.set_upstream_tokens(user.id, encrypt_token(settings.token_encryption_key, "gitea_tok"), None, time.time() + 3600) token = database.create_access_token("cursor", user.id) with patch("app.main.httpx.AsyncClient", FakeAsyncClient): resp = proxy.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "ping"}, headers={"Authorization": f"Bearer {token}", "Mcp-Session-Id": "s0"}) assert resp.status_code == 200 assert resp.headers["mcp-session-id"] == "sess-1" _, url, headers = FakeAsyncClient.calls[-1] assert url == "http://gitea-mcp:8080/mcp" assert headers["Authorization"] == "Bearer gitea_tok" assert headers["mcp-session-id"] == "s0" def test_proxy_refreshes_expired_token(proxy, database): settings = get_settings() key = settings.token_encryption_key user = database.upsert_user("gitea-2", "g2@example.com") database.set_upstream_tokens(user.id, encrypt_token(key, "gitea_old"), encrypt_token(key, "r1"), time.time() - 10) token = database.create_access_token("cursor", user.id) with patch("app.main.httpx.AsyncClient", FakeAsyncClient), patch( "app.main._oidc_discovery", return_value={"token_endpoint": "https://git.example/login/oauth/access_token"} ): resp = proxy.post("/mcp", json={}, headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 kinds = [c[0] for c in FakeAsyncClient.calls] assert kinds == ["post", "request"] assert FakeAsyncClient.calls[0][2]["refresh_token"] == "r1" assert FakeAsyncClient.calls[1][2]["Authorization"] == "Bearer gitea_new" def test_proxy_passes_through_unknown_token(proxy): with patch("app.main.httpx.AsyncClient", FakeAsyncClient): resp = proxy.post("/mcp", json={}, headers={"Authorization": "Bearer legacy_gitea_token"}) assert resp.status_code == 200 assert FakeAsyncClient.calls[-1][2]["Authorization"] == "Bearer legacy_gitea_token" def test_upstream_401_becomes_bridge_401(proxy): with patch("app.main.httpx.AsyncClient", FakeAsyncClient), patch.object( FakeAsyncClient, "upstream_response", FakeResp(401, content=b"authorization required") ): resp = proxy.post("/mcp", json={}, headers={"Authorization": "Bearer bad"}) assert resp.status_code == 401 assert "resource_metadata" in resp.headers["www-authenticate"] def test_unknown_token_rejected_when_passthrough_off(proxy, monkeypatch): monkeypatch.setenv("PASSTHROUGH_UNKNOWN_TOKENS", "false") get_settings.cache_clear() resp = proxy.post("/mcp", json={}, headers={"Authorization": "Bearer something"}) assert resp.status_code == 401 def test_proxy_authorize_without_upstream_token_goes_to_login(proxy, database, monkeypatch): user = database.upsert_user("gitea-3", None) import app.main as main monkeypatch.setattr(main, "session_user_id", lambda request: user.id) _, challenge = _pkce() resp = _authorize(proxy, "cursor", LOOPBACK, challenge) assert resp.status_code == 302 assert resp.headers["location"].startswith("/oidc/login?") def test_upstream_host_header(proxy, monkeypatch): monkeypatch.setenv("UPSTREAM_HOST_HEADER", "git-mcp.example") get_settings.cache_clear() with patch("app.main.httpx.AsyncClient", FakeAsyncClient): proxy.post("/mcp", json={}, headers={"Authorization": "Bearer legacy"}) assert FakeAsyncClient.calls[-1][2]["Host"] == "git-mcp.example" def test_no_host_override_by_default(proxy): with patch("app.main.httpx.AsyncClient", FakeAsyncClient): proxy.post("/mcp", json={}, headers={"Authorization": "Bearer legacy"}) assert "Host" not in FakeAsyncClient.calls[-1][2]