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>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-22 18:20:02 +09:00
commit 916d149ab5
18 files changed
+1494

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import hashlib
import base64
import time
from unittest.mock import AsyncMock, MagicMock, patch
from app.crypto import encrypt_token
from app.config import get_settings
def _pkce_pair() -> tuple[str, str]:
verifier = "test-verifier-string-long-enough-1234567890"
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
def test_health(client):
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_registration_disabled_by_flag(client, monkeypatch):
monkeypatch.setenv("DCR_ENABLED", "false")
get_settings.cache_clear()
resp = client.post("/register", json={"client_name": "x", "redirect_uris": ["http://localhost:1/cb"]})
assert resp.status_code == 400
assert resp.json()["error"] == "registration_not_supported"
def test_authorize_rejects_unknown_client(client):
_, challenge = _pkce_pair()
resp = client.get(
"/authorize",
params={
"response_type": "code",
"client_id": "unknown",
"redirect_uri": "http://localhost:8787/callback",
"code_challenge": challenge,
"code_challenge_method": "S256",
},
follow_redirects=False,
)
assert resp.status_code == 400
def test_authorize_starts_for_allowed_client(client, database, monkeypatch):
settings = get_settings()
user = database.upsert_user("sub-1", "u@example.com")
database.set_upstream_tokens(user.id, encrypt_token(settings.token_encryption_key, "gitea_tok"), None, time.time() + 3600)
import app.main as main
monkeypatch.setattr(main, "session_user_id", lambda request: user.id)
_, challenge = _pkce_pair()
resp = client.get(
"/authorize",
params={
"response_type": "code",
"client_id": "cursor",
"redirect_uri": "http://localhost:8787/callback",
"code_challenge": challenge,
"code_challenge_method": "S256",
"state": "abc",
},
follow_redirects=False,
)
assert resp.status_code == 302
assert resp.headers["location"].startswith("http://localhost:8787/callback?")
assert "code=" in resp.headers["location"]
assert "state=abc" in resp.headers["location"]
def test_mcp_without_token_returns_401(client):
resp = client.post("/mcp", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
assert resp.status_code == 401
def test_mcp_proxies_with_gitea_token(client, database):
settings = get_settings()
user = database.upsert_user("sub-2", "u2@example.com")
database.set_upstream_tokens(user.id, encrypt_token(settings.token_encryption_key, "gitea_secret"), None, time.time() + 3600)
token = database.create_access_token("cursor", user.id)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.content = b'{"ok":true}'
mock_response.headers = {"content-type": "application/json"}
mock_client = AsyncMock()
mock_client.request = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.main.httpx.AsyncClient", return_value=mock_client):
resp = client.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "tools/list", "id": 1},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
mock_client.request.assert_awaited_once()
call_kwargs = mock_client.request.await_args.kwargs
assert call_kwargs["headers"]["Authorization"] == "Bearer gitea_secret"