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

+7
View File
@@ -0,0 +1,7 @@
.git
.env
data
tests
.venv
__pycache__
.pytest_cache
+12
View File
@@ -0,0 +1,12 @@
# 운영: https://git-mcp.sejong.dev
PUBLIC_BASE_URL=https://git-mcp.sejong.dev
UPSTREAM_MCP_URL=http://gitea-mcp-oauth:8080/mcp
# gitea-mcp -oauth는 -oauth-public-url 호스트만 받는다
UPSTREAM_HOST_HEADER=git-mcp.sejong.dev
OIDC_ISSUER=https://git.sejong.dev
OIDC_CLIENT_ID=your-gitea-oauth-app-id
OIDC_CLIENT_SECRET=your-gitea-oauth-app-secret
# 비워 두면 동적 등록(DCR)만 쓴다. 고정 클라이언트가 필요하면 client_id|redirect_uri 콤마 구분
ALLOWED_MCP_CLIENTS=
# openssl rand -hex 32
TOKEN_ENCRYPTION_KEY=
+7
View File
@@ -0,0 +1,7 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.env
data/
*.db
+13
View File
@@ -0,0 +1,13 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
ENV LISTEN_HOST=0.0.0.0
ENV LISTEN_PORT=48080
ENV DATA_DIR=/data
EXPOSE 48080
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "48080"]
+78
View File
@@ -0,0 +1,78 @@
# gitea-mcp-oauth-bridge
Gitea에는 동적 클라이언트 등록(DCR)이 없어서, claude.ai·Cursor 같은 MCP 클라이언트가 URL만으로 OAuth 연결을 못 한다.
이 브리지가 OAuth 인가 서버 역할(DCR 포함)을 대신하고, 로그인은 Gitea로 받아 사용자별 Gitea 토큰을 암호화해 둔 뒤
MCP 요청을 gitea-mcp로 넘긴다. 각 사용자는 자기 Gitea 계정 권한으로 동작한다.
- 주소: `https://git-mcp.sejong.dev` (MCP 엔드포인트는 `/mcp`)
- 구성: 클라이언트 → Caddy → 브리지(NAS 13332) → `gitea-mcp-oauth` 컨테이너(호스트 포트 없음, 도커 네트워크 `gitea-mcp-oauth_default`)
```
클라이언트 --OAuth(MCP, DCR)--> 브리지 --OIDC--> Gitea (git.sejong.dev)
|-- Gitea 토큰 암호화 저장 (SQLite, data/bridge.db)
`-- MCP 프록시 --> http://gitea-mcp-oauth:8080/mcp
```
브리지가 발급하지 않은 Bearer 토큰(Gitea PAT 등)은 그대로 gitea-mcp로 넘긴다(`PASSTHROUGH_UNKNOWN_TOKENS`, 기본 켜짐).
그래서 PAT를 넣어 쓰던 클라이언트도 계속 동작한다.
## 클라이언트 연결
- claude.ai: 설정 → 커넥터 → 커스텀 커넥터 추가 → URL `https://git-mcp.sejong.dev/mcp`. 고급 설정은 비워 둔다.
- Claude Code: `claude mcp add --transport http gitea https://git-mcp.sejong.dev/mcp` 후 `/mcp`에서 인증.
- Cursor: `"gitea": { "url": "https://git-mcp.sejong.dev/mcp" }`
## 환경 변수
`.env.example` 참고. `.env`는 NAS에만 두고(권한 600) 리포에는 올리지 않는다.
| 이름 | 설명 |
|---|---|
| `PUBLIC_BASE_URL` | `https://git-mcp.sejong.dev` (발급자·콜백 주소의 기준) |
| `UPSTREAM_MCP_URL` | `http://gitea-mcp-oauth:8080/mcp` |
| `UPSTREAM_HOST_HEADER` | `git-mcp.sejong.dev`. gitea-mcp `-oauth`는 `-oauth-public-url` 호스트만 받는다 |
| `OIDC_ISSUER` | `https://git.sejong.dev` |
| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | 아래 Gitea OAuth2 앱 |
| `OIDC_SCOPES` | 기본 `openid profile email write:repository write:issue write:user write:organization write:notification write:package write:misc` |
| `ALLOWED_MCP_CLIENTS` | 고정 클라이언트 `client_id\|redirect_uri` 콤마 구분. 비우면 DCR만 |
| `TOKEN_ENCRYPTION_KEY` | Gitea 토큰 암호화·세션 키 (`openssl rand -hex 32`). 바꾸면 모두 다시 로그인해야 한다 |
| `PASSTHROUGH_UNKNOWN_TOKENS` | 기본 `true` |
| `DCR_ENABLED` | 기본 `true` |
## Gitea OAuth2 앱
Gitea → 설정 → 애플리케이션 → OAuth2 애플리케이션 생성.
- 이름: `gitea-mcp-oauth-bridge`
- Redirect URI: `https://git-mcp.sejong.dev/oidc/callback`
- **Confidential Client 체크** (브리지가 client secret으로 토큰을 받는다)
## 운영 (NAS)
```
cd /volume1/docker/gitea-mcp-oauth-bridge
git pull --ff-only
sudo -n /usr/local/bin/docker compose up -d --build
sudo -n /usr/local/bin/docker compose logs -f bridge
```
`gitea-mcp-oauth`(`/volume1/docker/gitea-mcp-oauth`)가 먼저 떠 있어야 한다(외부 네트워크 `gitea-mcp-oauth_default`).
## 개발
```
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
pytest
```
## 코드 레이아웃
| 경로 | 역할 |
|---|---|
| `app/main.py` | FastAPI: OAuth AS(메타데이터·DCR·authorize·token), Gitea OIDC 로그인, `/mcp` 프록시 |
| `app/dcr.py` | RFC 7591 등록 검증·레이트 리밋 |
| `app/config.py` | 환경 변수 |
| `app/db.py` | SQLite: 사용자·Gitea 토큰·OAuth 코드·액세스 토큰·등록 클라이언트 |
| `app/crypto.py` | 토큰 Fernet 암호화 |
| `tests/` | pytest (Gitea·gitea-mcp는 모킹) |
View File
Whitespace-only changes.
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
def parse_allowed_mcp_clients(raw: str) -> dict[str, set[str]]:
out: dict[str, set[str]] = {}
for part in raw.split(","):
part = part.strip()
if not part:
continue
client_id, _, redirect_uri = part.partition("|")
client_id = client_id.strip()
redirect_uri = redirect_uri.strip()
if not client_id or not redirect_uri:
continue
out.setdefault(client_id, set()).add(redirect_uri)
return out
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
public_base_url: str = "http://127.0.0.1:48080"
listen_host: str = "0.0.0.0"
listen_port: int = 48080
upstream_mcp_url: str = "http://gitea-mcp:8080/mcp"
oidc_issuer: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_scopes: str = (
"openid profile email write:repository write:issue write:user"
" write:organization write:notification write:package write:misc"
)
allowed_mcp_clients: str = ""
token_encryption_key: str = ""
data_dir: str = "./data"
dcr_enabled: bool = True
dcr_max_clients: int = 2000
# Forward bearer tokens the bridge did not issue (e.g. Gitea PATs) unchanged.
passthrough_unknown_tokens: bool = True
# Optional Host header for the upstream request (gitea-mcp rejects other hosts).
upstream_host_header: str = ""
@property
def issuer(self) -> str:
return self.public_base_url.rstrip("/")
@property
def mcp_resource_url(self) -> str:
return f"{self.issuer}/mcp"
@property
def allowed_clients(self) -> dict[str, set[str]]:
return parse_allowed_mcp_clients(self.allowed_mcp_clients)
@property
def session_secret(self) -> bytes:
return self.token_encryption_key.encode("utf-8")
@lru_cache
def get_settings() -> Settings:
return Settings()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
def make_fernet(encryption_key: str) -> Fernet:
digest = hashlib.sha256(encryption_key.encode("utf-8")).digest()
key = base64.urlsafe_b64encode(digest)
return Fernet(key)
def encrypt_token(encryption_key: str, plaintext: str) -> str:
return make_fernet(encryption_key).encrypt(plaintext.encode("utf-8")).decode("ascii")
def decrypt_token(encryption_key: str, ciphertext: str) -> str | None:
try:
return make_fernet(encryption_key).decrypt(ciphertext.encode("ascii")).decode("utf-8")
except InvalidToken:
return None
+259
View File
@@ -0,0 +1,259 @@
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
import sqlite3
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
oidc_sub TEXT NOT NULL UNIQUE,
email TEXT,
upstream_access TEXT,
upstream_refresh TEXT,
upstream_expires_at REAL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS oauth_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL,
user_id INTEGER NOT NULL,
expires_at REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS access_tokens (
token TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
expires_at REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS oauth_clients (
client_id TEXT PRIMARY KEY,
client_name TEXT,
redirect_uris TEXT NOT NULL,
registration_token_hash TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY,
next_url TEXT NOT NULL,
expires_at REAL NOT NULL
);
"""
@dataclass
class User:
id: int
oidc_sub: str
email: str | None
class Database:
def __init__(self, data_dir: str) -> None:
path = Path(data_dir)
path.mkdir(parents=True, exist_ok=True)
self._path = path / "bridge.db"
self._init()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._path, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def _init(self) -> None:
with self._connect() as conn:
conn.executescript(SCHEMA)
@contextmanager
def connection(self) -> Iterator[sqlite3.Connection]:
conn = self._connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def upsert_user(self, oidc_sub: str, email: str | None) -> User:
now = time.time()
with self.connection() as conn:
conn.execute(
"""
INSERT INTO users (oidc_sub, email, created_at)
VALUES (?, ?, ?)
ON CONFLICT(oidc_sub) DO UPDATE SET email = COALESCE(excluded.email, users.email)
""",
(oidc_sub, email, now),
)
row = conn.execute(
"SELECT id, oidc_sub, email FROM users WHERE oidc_sub = ?",
(oidc_sub,),
).fetchone()
assert row is not None
return User(row["id"], row["oidc_sub"], row["email"])
def get_user_by_id(self, user_id: int) -> User | None:
with self.connection() as conn:
row = conn.execute(
"SELECT id, oidc_sub, email FROM users WHERE id = ?",
(user_id,),
).fetchone()
if row is None:
return None
return User(row["id"], row["oidc_sub"], row["email"])
def create_oauth_code(
self,
client_id: str,
redirect_uri: str,
code_challenge: str,
code_challenge_method: str,
user_id: int,
ttl_seconds: int = 600,
) -> str:
code = secrets.token_urlsafe(32)
expires_at = time.time() + ttl_seconds
with self.connection() as conn:
conn.execute(
"""
INSERT INTO oauth_codes
(code, client_id, redirect_uri, code_challenge, code_challenge_method, user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(code, client_id, redirect_uri, code_challenge, code_challenge_method, user_id, expires_at),
)
return code
def consume_oauth_code(self, code: str) -> sqlite3.Row | None:
now = time.time()
with self.connection() as conn:
row = conn.execute(
"SELECT * FROM oauth_codes WHERE code = ? AND expires_at > ?",
(code, now),
).fetchone()
if row is None:
return None
conn.execute("DELETE FROM oauth_codes WHERE code = ?", (code,))
return row
def create_access_token(self, client_id: str, user_id: int, ttl_seconds: int = 86400 * 7) -> str:
token = secrets.token_urlsafe(48)
expires_at = time.time() + ttl_seconds
with self.connection() as conn:
conn.execute(
"INSERT INTO access_tokens (token, client_id, user_id, expires_at) VALUES (?, ?, ?, ?)",
(token, client_id, user_id, expires_at),
)
return token
def get_access_token(self, token: str) -> sqlite3.Row | None:
now = time.time()
with self.connection() as conn:
return conn.execute(
"SELECT * FROM access_tokens WHERE token = ? AND expires_at > ?",
(token, now),
).fetchone()
def save_oidc_state(self, state: str, next_url: str, ttl_seconds: int = 600) -> None:
expires_at = time.time() + ttl_seconds
with self.connection() as conn:
conn.execute(
"INSERT INTO oidc_states (state, next_url, expires_at) VALUES (?, ?, ?)",
(state, next_url, expires_at),
)
def pop_oidc_state(self, state: str) -> str | None:
now = time.time()
with self.connection() as conn:
row = conn.execute(
"SELECT next_url FROM oidc_states WHERE state = ? AND expires_at > ?",
(state, now),
).fetchone()
if row is None:
return None
conn.execute("DELETE FROM oidc_states WHERE state = ?", (state,))
return row["next_url"]
# ---- dynamic client registration (RFC 7591 / 7592) ----
@staticmethod
def _hash(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def register_client(
self, client_name: str, redirect_uris: list[str], max_clients: int
) -> tuple[str, str, int] | None:
client_id = "dcr-" + secrets.token_urlsafe(24)
reg_token = secrets.token_urlsafe(32)
now = time.time()
with self.connection() as conn:
(count,) = conn.execute("SELECT COUNT(*) FROM oauth_clients").fetchone()
if count >= max_clients:
return None
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris, registration_token_hash, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(client_id, client_name, json.dumps(redirect_uris), self._hash(reg_token), now),
)
return client_id, reg_token, int(now)
def get_client(self, client_id: str) -> sqlite3.Row | None:
with self.connection() as conn:
return conn.execute(
"SELECT * FROM oauth_clients WHERE client_id = ?", (client_id,)
).fetchone()
def get_client_redirect_uris(self, client_id: str) -> list[str] | None:
row = self.get_client(client_id)
if row is None:
return None
return list(json.loads(row["redirect_uris"]))
def check_registration_token(self, client_id: str, token: str) -> bool:
row = self.get_client(client_id)
if row is None or not token:
return False
return hmac.compare_digest(row["registration_token_hash"], self._hash(token))
def delete_client(self, client_id: str) -> None:
with self.connection() as conn:
conn.execute("DELETE FROM oauth_clients WHERE client_id = ?", (client_id,))
conn.execute("DELETE FROM access_tokens WHERE client_id = ?", (client_id,))
conn.execute("DELETE FROM oauth_codes WHERE client_id = ?", (client_id,))
# ---- upstream (Gitea) OAuth tokens ----
def set_upstream_tokens(
self, user_id: int, enc_access: str, enc_refresh: str | None, expires_at: float
) -> None:
with self.connection() as conn:
conn.execute(
"UPDATE users SET upstream_access = ?, upstream_refresh = COALESCE(?, upstream_refresh),"
" upstream_expires_at = ? WHERE id = ?",
(enc_access, enc_refresh, expires_at, user_id),
)
def get_upstream_tokens(self, user_id: int) -> sqlite3.Row | None:
with self.connection() as conn:
return conn.execute(
"SELECT upstream_access, upstream_refresh, upstream_expires_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
+55
View File
@@ -0,0 +1,55 @@
"""RFC 7591 dynamic client registration helpers (G3X, 2026-10-19)."""
from __future__ import annotations
import re
import time
from collections import defaultdict, deque
from urllib.parse import urlparse
from fastapi import Request
_SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.\-]*$")
_BAD_SCHEMES = {"javascript", "data", "file", "vbscript", "about", "blob", "ftp", "ws", "wss"}
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
SUPPORTED_GRANTS = {"authorization_code", "refresh_token"}
def valid_redirect_uri(uri: object) -> bool:
"""https URLs, http loopback URLs, or private app schemes (cursor://...)."""
if not isinstance(uri, str) or not uri or len(uri) > 2000 or "*" in uri:
return False
if any(c.isspace() or ord(c) < 32 for c in uri):
return False
p = urlparse(uri)
if p.fragment or not p.scheme:
return False
if p.scheme == "https":
return bool(p.hostname)
if p.scheme == "http":
return p.hostname in _LOOPBACK
return bool(_SCHEME_RE.match(p.scheme)) and p.scheme not in _BAD_SCHEMES
class RateLimiter:
def __init__(self, max_events: int, window_seconds: float) -> None:
self.max_events = max_events
self.window = window_seconds
self._events: dict[str, deque[float]] = defaultdict(deque)
def hit(self, key: str) -> bool:
"""Record an event; return True when the limit is exceeded."""
now = time.time()
q = self._events[key]
while q and q[0] < now - self.window:
q.popleft()
if len(q) >= self.max_events:
return True
q.append(now)
return False
def client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else "unknown"
+502
View File
@@ -0,0 +1,502 @@
from __future__ import annotations
import asyncio
import secrets
import time
from typing import Any
from urllib.parse import urlencode
import httpx
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from app.config import Settings, get_settings
from app.crypto import decrypt_token, encrypt_token
from app.db import Database
from app.dcr import SUPPORTED_GRANTS, RateLimiter, client_ip, valid_redirect_uri
from app.pkce import verify_pkce_s256
app = FastAPI(title="Gitea MCP OAuth Bridge")
_db: Database | None = None
def db() -> Database:
global _db
if _db is None:
_db = Database(get_settings().data_dir)
return _db
def _install_session_middleware() -> None:
settings = get_settings()
app.add_middleware(SessionMiddleware, secret_key=settings.session_secret, same_site="lax")
_install_session_middleware()
def authorization_server_metadata(settings: Settings) -> dict[str, Any]:
base = settings.issuer
return {
"issuer": base,
"authorization_endpoint": f"{base}/authorize",
"token_endpoint": f"{base}/token",
"registration_endpoint": f"{base}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["openid"],
}
def protected_resource_metadata(settings: Settings, resource: str) -> dict[str, Any]:
return {
"resource": resource,
"authorization_servers": [settings.issuer],
"bearer_methods_supported": ["header"],
}
def validate_mcp_client(settings: Settings, client_id: str, redirect_uri: str) -> bool:
allowed = settings.allowed_clients.get(client_id)
if allowed is not None:
return redirect_uri in allowed
if not settings.dcr_enabled:
return False
registered = db().get_client_redirect_uris(client_id)
return registered is not None and redirect_uri in registered
def session_user_id(request: Request) -> int | None:
raw = request.session.get("user_id")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def mcp_unauthorized_response(settings: Settings) -> JSONResponse:
resource_meta = protected_resource_metadata(settings, settings.mcp_resource_url)
www = f'Bearer realm="mcp", resource_metadata="{settings.issuer}/.well-known/oauth-protected-resource/mcp"'
return JSONResponse(
status_code=401,
content={
"error": "unauthorized",
"message": "Valid OAuth access token required.",
"resource_metadata": resource_meta,
},
headers={"WWW-Authenticate": www},
)
@app.get("/health")
async def health() -> dict[str, bool]:
return {"ok": True}
@app.get("/.well-known/oauth-authorization-server")
@app.get("/mcp/.well-known/oauth-authorization-server")
async def well_known_as(settings: Settings = Depends(get_settings)) -> dict[str, Any]:
return authorization_server_metadata(settings)
@app.get("/.well-known/oauth-protected-resource")
async def well_known_pr(settings: Settings = Depends(get_settings)) -> dict[str, Any]:
return protected_resource_metadata(settings, settings.issuer)
@app.get("/.well-known/oauth-protected-resource/mcp")
async def well_known_pr_mcp(settings: Settings = Depends(get_settings)) -> dict[str, Any]:
return protected_resource_metadata(settings, settings.mcp_resource_url)
_dcr_limiter = RateLimiter(max_events=30, window_seconds=600)
def _oauth_error(error: str, description: str, status: int = 400) -> JSONResponse:
return JSONResponse(status_code=status, content={"error": error, "error_description": description})
def _client_metadata(settings: Settings, row: Any) -> dict[str, Any]:
import json
return {
"client_id": row["client_id"],
"client_id_issued_at": int(row["created_at"]),
"client_name": row["client_name"] or "",
"redirect_uris": json.loads(row["redirect_uris"]),
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"registration_client_uri": f"{settings.issuer}/register/{row['client_id']}",
}
@app.post("/register")
async def register_client(request: Request, settings: Settings = Depends(get_settings)) -> JSONResponse:
"""RFC 7591 dynamic client registration for public PKCE clients."""
if not settings.dcr_enabled:
return _oauth_error(
"registration_not_supported",
"Register MCP clients via ALLOWED_MCP_CLIENTS; dynamic registration is disabled.",
)
if _dcr_limiter.hit(client_ip(request)):
return _oauth_error("too_many_requests", "Rate limit exceeded for client registration", 429)
try:
body = await request.json()
except Exception:
body = None
if not isinstance(body, dict):
return _oauth_error("invalid_client_metadata", "Request body must be a JSON object")
uris = body.get("redirect_uris")
if not isinstance(uris, list) or not uris or len(uris) > 20:
return _oauth_error("invalid_redirect_uri", "redirect_uris must be a non-empty list")
bad = [u for u in uris if not valid_redirect_uri(u)]
if bad:
return _oauth_error(
"invalid_redirect_uri",
"Allowed redirect URIs: https://..., http://localhost or 127.0.0.1, or an app scheme "
f"such as cursor://... Rejected: {bad[:3]}",
)
grants = body.get("grant_types") or ["authorization_code"]
if not isinstance(grants, list) or "authorization_code" not in grants or not set(grants) <= SUPPORTED_GRANTS:
return _oauth_error("invalid_client_metadata", "grant_types must include authorization_code")
rtypes = body.get("response_types") or ["code"]
if rtypes != ["code"]:
return _oauth_error("invalid_client_metadata", 'Only response_types ["code"] is supported')
# Public clients only: any requested auth method is answered with "none"
# (RFC 7591 lets the server replace requested values); PKCE is mandatory.
name = str(body.get("client_name") or "")[:200]
created = db().register_client(name, uris, settings.dcr_max_clients)
if created is None:
return _oauth_error("invalid_client_metadata", "Client registration limit reached")
client_id, reg_token, issued_at = created
content = _client_metadata(settings, db().get_client(client_id))
content["registration_access_token"] = reg_token
return JSONResponse(status_code=201, content=content, headers={"Cache-Control": "no-store"})
def _registration_auth(request: Request, client_id: str) -> bool:
auth = request.headers.get("authorization", "")
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
return db().check_registration_token(client_id, token)
@app.get("/register/{client_id}")
async def read_client(client_id: str, request: Request, settings: Settings = Depends(get_settings)) -> JSONResponse:
if not _registration_auth(request, client_id):
return _oauth_error("invalid_token", "Invalid registration access token", 401)
return JSONResponse(content=_client_metadata(settings, db().get_client(client_id)))
@app.delete("/register/{client_id}")
async def delete_client(client_id: str, request: Request) -> Response:
if not _registration_auth(request, client_id):
return _oauth_error("invalid_token", "Invalid registration access token", 401)
db().delete_client(client_id)
return Response(status_code=204)
@app.get("/authorize")
async def authorize(
request: Request,
response_type: str = Query(...),
client_id: str = Query(...),
redirect_uri: str = Query(...),
code_challenge: str = Query(...),
code_challenge_method: str = Query("S256"),
state: str | None = Query(None),
settings: Settings = Depends(get_settings),
) -> Response:
if response_type != "code":
raise HTTPException(status_code=400, detail="unsupported response_type")
if code_challenge_method != "S256":
raise HTTPException(status_code=400, detail="PKCE S256 required")
if not validate_mcp_client(settings, client_id, redirect_uri):
raise HTTPException(status_code=400, detail="invalid client_id or redirect_uri")
user_id = session_user_id(request)
if user_id is None:
query = request.url.query
next_url = f"/authorize?{query}" if query else "/authorize"
return RedirectResponse(url=f"/oidc/login?{urlencode({'next': next_url})}", status_code=302)
user = db().get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=401, detail="Login required")
tokens = db().get_upstream_tokens(user.id)
if tokens is None or not tokens["upstream_access"]:
query = request.url.query
next_url = f"/authorize?{query}" if query else "/authorize"
return RedirectResponse(url=f"/oidc/login?{urlencode({'next': next_url})}", status_code=302)
code = db().create_oauth_code(
client_id=client_id,
redirect_uri=redirect_uri,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
user_id=user.id,
)
params = {"code": code}
if state:
params["state"] = state
location = f"{redirect_uri}?{urlencode(params)}"
return RedirectResponse(url=location, status_code=302)
@app.post("/token")
async def token(
request: Request,
settings: Settings = Depends(get_settings),
) -> JSONResponse:
form = await request.form()
grant_type = form.get("grant_type")
if grant_type != "authorization_code":
raise HTTPException(status_code=400, detail="unsupported grant_type")
code = form.get("code")
redirect_uri = form.get("redirect_uri")
client_id = form.get("client_id")
code_verifier = form.get("code_verifier")
if not all(isinstance(x, str) for x in (code, redirect_uri, client_id, code_verifier)):
raise HTTPException(status_code=400, detail="invalid request")
assert isinstance(code, str)
assert isinstance(redirect_uri, str)
assert isinstance(client_id, str)
assert isinstance(code_verifier, str)
if not validate_mcp_client(settings, client_id, redirect_uri):
raise HTTPException(status_code=400, detail="invalid client_id or redirect_uri")
row = db().consume_oauth_code(code)
if row is None:
raise HTTPException(status_code=400, detail="invalid or expired code")
if row["client_id"] != client_id or row["redirect_uri"] != redirect_uri:
raise HTTPException(status_code=400, detail="client or redirect mismatch")
if not verify_pkce_s256(code_verifier, row["code_challenge"]):
raise HTTPException(status_code=400, detail="invalid code_verifier")
access_token = db().create_access_token(client_id=client_id, user_id=int(row["user_id"]))
return JSONResponse(
content={
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 604800,
}
)
def _oidc_discovery(settings: Settings) -> dict[str, Any]:
issuer = settings.oidc_issuer.rstrip("/")
with httpx.Client(timeout=15.0) as client:
resp = client.get(f"{issuer}/.well-known/openid-configuration")
resp.raise_for_status()
return resp.json()
@app.get("/oidc/login")
async def oidc_login(
request: Request,
next: str = Query("/"),
settings: Settings = Depends(get_settings),
) -> RedirectResponse:
state = secrets.token_urlsafe(24)
db().save_oidc_state(state, next)
discovery = _oidc_discovery(settings)
auth_endpoint = discovery["authorization_endpoint"]
redirect_uri = f"{settings.issuer}/oidc/callback"
params = {
"client_id": settings.oidc_client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"scope": settings.oidc_scopes,
"state": state,
}
return RedirectResponse(url=f"{auth_endpoint}?{urlencode(params)}", status_code=302)
@app.get("/oidc/callback")
async def oidc_callback(
request: Request,
code: str | None = Query(None),
state: str | None = Query(None),
settings: Settings = Depends(get_settings),
) -> RedirectResponse:
if not code or not state:
raise HTTPException(status_code=400, detail="missing code or state")
next_url = db().pop_oidc_state(state)
if next_url is None:
raise HTTPException(status_code=400, detail="invalid or expired state")
discovery = _oidc_discovery(settings)
token_endpoint = discovery["token_endpoint"]
redirect_uri = f"{settings.issuer}/oidc/callback"
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": settings.oidc_client_id,
"client_secret": settings.oidc_client_secret,
}
with httpx.Client(timeout=15.0) as client:
token_resp = client.post(token_endpoint, data=data)
token_resp.raise_for_status()
tokens = token_resp.json()
id_token = tokens.get("id_token")
access = tokens.get("access_token")
sub = None
email = None
if id_token:
import base64
import json
payload_b64 = id_token.split(".")[1]
padding = "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
sub = payload.get("sub")
email = payload.get("email")
if not sub and access:
userinfo = discovery.get("userinfo_endpoint")
if userinfo:
with httpx.Client(timeout=15.0) as client:
ui = client.get(userinfo, headers={"Authorization": f"Bearer {access}"})
if ui.status_code == 200:
body = ui.json()
sub = body.get("sub")
email = body.get("email")
if not sub:
raise HTTPException(status_code=400, detail="could not resolve OIDC subject")
user = db().upsert_user(oidc_sub=str(sub), email=email)
request.session["user_id"] = user.id
if not access:
raise HTTPException(status_code=400, detail="upstream returned no access token")
_store_upstream_tokens(settings, user.id, tokens)
return RedirectResponse(url=next_url, status_code=302)
def _store_upstream_tokens(settings: Settings, user_id: int, tokens: dict[str, Any]) -> None:
key = settings.token_encryption_key
refresh = tokens.get("refresh_token")
expires_in = float(tokens.get("expires_in") or 3600)
db().set_upstream_tokens(
user_id,
encrypt_token(key, tokens["access_token"]),
encrypt_token(key, refresh) if refresh else None,
time.time() + expires_in,
)
_refresh_locks: dict[int, asyncio.Lock] = {}
async def _upstream_access_token(settings: Settings, user_id: int) -> str | None:
"""Current upstream access token for the user, refreshing it when it is about to expire."""
key = settings.token_encryption_key
def fresh() -> str | None:
row = db().get_upstream_tokens(user_id)
if row is None or not row["upstream_access"]:
return None
if (row["upstream_expires_at"] or 0) > time.time() + 60:
return decrypt_token(key, row["upstream_access"])
return None
token = fresh()
if token:
return token
lock = _refresh_locks.setdefault(user_id, asyncio.Lock())
async with lock:
token = fresh()
if token:
return token
row = db().get_upstream_tokens(user_id)
refresh = decrypt_token(key, row["upstream_refresh"]) if row is not None and row["upstream_refresh"] else None
if not refresh:
return None
discovery = _oidc_discovery(settings)
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
discovery["token_endpoint"],
data={
"grant_type": "refresh_token",
"refresh_token": refresh,
"client_id": settings.oidc_client_id,
"client_secret": settings.oidc_client_secret,
},
)
if resp.status_code != 200:
return None
tokens = resp.json()
if not tokens.get("access_token"):
return None
_store_upstream_tokens(settings, user_id, tokens)
return tokens["access_token"]
_FORWARD_REQUEST_HEADERS = ("mcp-session-id", "mcp-protocol-version", "last-event-id")
_FORWARD_RESPONSE_HEADERS = ("mcp-session-id",)
async def _proxy_mcp(request: Request, settings: Settings) -> Response:
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return mcp_unauthorized_response(settings)
token = auth[7:].strip()
row = db().get_access_token(token)
if row is None:
if not (settings.passthrough_unknown_tokens and token):
return mcp_unauthorized_response(settings)
upstream_auth = f"Bearer {token}"
else:
upstream_token = await _upstream_access_token(settings, int(row["user_id"]))
if not upstream_token:
return mcp_unauthorized_response(settings)
upstream_auth = f"Bearer {upstream_token}"
body = await request.body()
headers = {
"Authorization": upstream_auth,
"Content-Type": request.headers.get("content-type", "application/json"),
"Accept": request.headers.get("accept", "application/json"),
}
for name in _FORWARD_REQUEST_HEADERS:
value = request.headers.get(name)
if value:
headers[name] = value
if settings.upstream_host_header:
headers["Host"] = settings.upstream_host_header
async with httpx.AsyncClient(timeout=120.0) as client:
upstream = await client.request(
method=request.method,
url=settings.upstream_mcp_url,
content=body,
headers=headers,
)
if upstream.status_code == 401:
return mcp_unauthorized_response(settings)
out_headers = {"Content-Type": upstream.headers.get("content-type", "application/json")}
for name in _FORWARD_RESPONSE_HEADERS:
value = upstream.headers.get(name)
if isinstance(value, str) and value:
out_headers[name] = value
return Response(content=upstream.content, status_code=upstream.status_code, headers=out_headers)
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
async def mcp_endpoint(request: Request, settings: Settings = Depends(get_settings)) -> Response:
if request.method == "GET":
return Response(status_code=405, headers={"Allow": "POST, DELETE"})
return await _proxy_mcp(request, settings)
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
import base64
import hashlib
def verify_pkce_s256(code_verifier: str, code_challenge: str) -> bool:
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return computed == code_challenge.rstrip("=")
+25
View File
@@ -0,0 +1,25 @@
# Gitea MCP OAuth bridge in front of gitea-mcp-oauth (git-mcp.sejong.dev via Caddy).
# Takes host port 13332, which Caddy forwards git-mcp.sejong.dev to; gitea-mcp-oauth
# has no host port and is reached over its compose network.
services:
bridge:
build: .
image: gitea-mcp-oauth-bridge:local
container_name: gitea-mcp-oauth-bridge
restart: unless-stopped
ports:
- "13332:48080"
env_file:
- .env
volumes:
- ./data:/data
labels:
- com.centurylinklabs.watchtower.enable=false
networks:
- default
- gitea-mcp
networks:
gitea-mcp:
external: true
name: gitea-mcp-oauth_default
+4
View File
@@ -0,0 +1,4 @@
[pytest]
asyncio_mode = auto
testpaths = tests
pythonpath = .
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
httpx>=0.27.0
cryptography>=43.0.0
python-multipart>=0.0.12
itsdangerous>=2.2.0
pydantic-settings>=2.6.0
pytest>=8.3.0
pytest-asyncio>=0.24.0
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("TOKEN_ENCRYPTION_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
os.environ.setdefault("PUBLIC_BASE_URL", "http://testserver")
os.environ.setdefault("OIDC_CLIENT_ID", "test-oidc-client")
os.environ.setdefault("OIDC_CLIENT_SECRET", "test-oidc-secret")
os.environ.setdefault("ALLOWED_MCP_CLIENTS", "cursor|http://localhost:8787/callback")
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
from app.config import get_settings
get_settings.cache_clear()
import app.main as main
main._db = None
with TestClient(main.app) as test_client:
yield test_client
@pytest.fixture
def database(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path))
from app.config import get_settings
get_settings.cache_clear()
import app.main as main
main._db = None
return main.db()
+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"
+279
View File
@@ -0,0 +1,279 @@
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]