From 916d149ab509d334a4f0eaebabbf6e11973e403e Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Thu, 22 Oct 2026 18:20:02 +0900 Subject: [PATCH] =?UTF-8?q?Gitea=20MCP=20OAuth=20=EB=B8=8C=EB=A6=AC?= =?UTF-8?q?=EC=A7=80=20(DCR,=20Gitea=20=EB=A1=9C=EA=B7=B8=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea에 동적 클라이언트 등록이 없어 MCP 클라이언트가 URL만으로 OAuth 연결을 못 하는 문제를 이 브리지가 인가 서버 역할을 대신해 해결한다. G3X의 plane-mcp-oauth-bridge(upstream_oauth 모드)에서 가져와 Gitea 전용으로 정리했다: Plane PAT 연결 페이지·워크스페이스 헤더·bridge_mode 분기를 없앴다. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 7 + .env.example | 12 ++ .gitignore | 7 + Dockerfile | 13 ++ README.md | 78 +++++++ app/__init__.py | 0 app/config.py | 66 ++++++ app/crypto.py | 23 ++ app/db.py | 259 ++++++++++++++++++++++ app/dcr.py | 55 +++++ app/main.py | 502 +++++++++++++++++++++++++++++++++++++++++++ app/pkce.py | 10 + compose.yaml | 25 +++ pytest.ini | 4 + requirements.txt | 9 + tests/conftest.py | 37 ++++ tests/test_bridge.py | 108 ++++++++++ tests/test_dcr.py | 279 ++++++++++++++++++++++++ 18 files changed, 1494 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/crypto.py create mode 100644 app/db.py create mode 100644 app/dcr.py create mode 100644 app/main.py create mode 100644 app/pkce.py create mode 100644 compose.yaml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 tests/conftest.py create mode 100644 tests/test_bridge.py create mode 100644 tests/test_dcr.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2493432 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.env +data +tests +.venv +__pycache__ +.pytest_cache diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6a483b6 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac1662f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.env +data/ +*.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..067cab7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c7e486 --- /dev/null +++ b/README.md @@ -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는 모킹) | diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..b923aa3 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/crypto.py b/app/crypto.py new file mode 100644 index 0000000..e508526 --- /dev/null +++ b/app/crypto.py @@ -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 diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..c516b6a --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/dcr.py b/app/dcr.py new file mode 100644 index 0000000..4cbfa09 --- /dev/null +++ b/app/dcr.py @@ -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" diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a3a2071 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/pkce.py b/app/pkce.py new file mode 100644 index 0000000..20fafb6 --- /dev/null +++ b/app/pkce.py @@ -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("=") diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..21d0da5 --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..8f32afa --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +asyncio_mode = auto +testpaths = tests +pythonpath = . diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a3fc6d1 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..748b15e --- /dev/null +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_bridge.py b/tests/test_bridge.py new file mode 100644 index 0000000..1640977 --- /dev/null +++ b/tests/test_bridge.py @@ -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" diff --git a/tests/test_dcr.py b/tests/test_dcr.py new file mode 100644 index 0000000..19ae053 --- /dev/null +++ b/tests/test_dcr.py @@ -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]