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