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:
commit
916d149ab5
18 files changed
+1494
No files matched your search
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.env
|
||||
data
|
||||
tests
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
@@ -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=
|
||||
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.env
|
||||
data/
|
||||
*.db
|
||||
+13
@@ -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"]
|
||||
@@ -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는 모킹) |
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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("=")
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user