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
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("=")
|
||||
Reference in new issue
Block a user