Files
gitea-mcp-oauth-bridge/tests/test_dcr.py
T
ysj7705andClaude Opus 5.5 916d149ab5 Gitea MCP OAuth 브리지 (DCR, Gitea 로그인)
Gitea에 동적 클라이언트 등록이 없어 MCP 클라이언트가 URL만으로 OAuth 연결을
못 하는 문제를 이 브리지가 인가 서버 역할을 대신해 해결한다. G3X의
plane-mcp-oauth-bridge(upstream_oauth 모드)에서 가져와 Gitea 전용으로 정리했다:
Plane PAT 연결 페이지·워크스페이스 헤더·bridge_mode 분기를 없앴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:20:02 +09:00

280 lines
11 KiB
Python

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]