Gitea MCP OAuth 브리지 (DCR, Gitea 로그인)

Gitea에 동적 클라이언트 등록이 없어 MCP 클라이언트가 URL만으로 OAuth 연결을
못 하는 문제를 이 브리지가 인가 서버 역할을 대신해 해결한다. G3X의
plane-mcp-oauth-bridge(upstream_oauth 모드)에서 가져와 Gitea 전용으로 정리했다:
Plane PAT 연결 페이지·워크스페이스 헤더·bridge_mode 분기를 없앴다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-22 18:20:02 +09:00
commit 916d149ab5
18 files changed
+1494

No files matched your search

View File
Whitespace-only changes.
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
def parse_allowed_mcp_clients(raw: str) -> dict[str, set[str]]:
out: dict[str, set[str]] = {}
for part in raw.split(","):
part = part.strip()
if not part:
continue
client_id, _, redirect_uri = part.partition("|")
client_id = client_id.strip()
redirect_uri = redirect_uri.strip()
if not client_id or not redirect_uri:
continue
out.setdefault(client_id, set()).add(redirect_uri)
return out
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
public_base_url: str = "http://127.0.0.1:48080"
listen_host: str = "0.0.0.0"
listen_port: int = 48080
upstream_mcp_url: str = "http://gitea-mcp:8080/mcp"
oidc_issuer: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_scopes: str = (
"openid profile email write:repository write:issue write:user"
" write:organization write:notification write:package write:misc"
)
allowed_mcp_clients: str = ""
token_encryption_key: str = ""
data_dir: str = "./data"
dcr_enabled: bool = True
dcr_max_clients: int = 2000
# Forward bearer tokens the bridge did not issue (e.g. Gitea PATs) unchanged.
passthrough_unknown_tokens: bool = True
# Optional Host header for the upstream request (gitea-mcp rejects other hosts).
upstream_host_header: str = ""
@property
def issuer(self) -> str:
return self.public_base_url.rstrip("/")
@property
def mcp_resource_url(self) -> str:
return f"{self.issuer}/mcp"
@property
def allowed_clients(self) -> dict[str, set[str]]:
return parse_allowed_mcp_clients(self.allowed_mcp_clients)
@property
def session_secret(self) -> bytes:
return self.token_encryption_key.encode("utf-8")
@lru_cache
def get_settings() -> Settings:
return Settings()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import base64
import hashlib
from cryptography.fernet import Fernet, InvalidToken
def make_fernet(encryption_key: str) -> Fernet:
digest = hashlib.sha256(encryption_key.encode("utf-8")).digest()
key = base64.urlsafe_b64encode(digest)
return Fernet(key)
def encrypt_token(encryption_key: str, plaintext: str) -> str:
return make_fernet(encryption_key).encrypt(plaintext.encode("utf-8")).decode("ascii")
def decrypt_token(encryption_key: str, ciphertext: str) -> str | None:
try:
return make_fernet(encryption_key).decrypt(ciphertext.encode("ascii")).decode("utf-8")
except InvalidToken:
return None
+259
View File
@@ -0,0 +1,259 @@
from __future__ import annotations
import hashlib
import hmac
import json
import secrets
import sqlite3
import time
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
SCHEMA = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
oidc_sub TEXT NOT NULL UNIQUE,
email TEXT,
upstream_access TEXT,
upstream_refresh TEXT,
upstream_expires_at REAL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS oauth_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL,
code_challenge_method TEXT NOT NULL,
user_id INTEGER NOT NULL,
expires_at REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS access_tokens (
token TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
expires_at REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS oauth_clients (
client_id TEXT PRIMARY KEY,
client_name TEXT,
redirect_uris TEXT NOT NULL,
registration_token_hash TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY,
next_url TEXT NOT NULL,
expires_at REAL NOT NULL
);
"""
@dataclass
class User:
id: int
oidc_sub: str
email: str | None
class Database:
def __init__(self, data_dir: str) -> None:
path = Path(data_dir)
path.mkdir(parents=True, exist_ok=True)
self._path = path / "bridge.db"
self._init()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._path, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def _init(self) -> None:
with self._connect() as conn:
conn.executescript(SCHEMA)
@contextmanager
def connection(self) -> Iterator[sqlite3.Connection]:
conn = self._connect()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def upsert_user(self, oidc_sub: str, email: str | None) -> User:
now = time.time()
with self.connection() as conn:
conn.execute(
"""
INSERT INTO users (oidc_sub, email, created_at)
VALUES (?, ?, ?)
ON CONFLICT(oidc_sub) DO UPDATE SET email = COALESCE(excluded.email, users.email)
""",
(oidc_sub, email, now),
)
row = conn.execute(
"SELECT id, oidc_sub, email FROM users WHERE oidc_sub = ?",
(oidc_sub,),
).fetchone()
assert row is not None
return User(row["id"], row["oidc_sub"], row["email"])
def get_user_by_id(self, user_id: int) -> User | None:
with self.connection() as conn:
row = conn.execute(
"SELECT id, oidc_sub, email FROM users WHERE id = ?",
(user_id,),
).fetchone()
if row is None:
return None
return User(row["id"], row["oidc_sub"], row["email"])
def create_oauth_code(
self,
client_id: str,
redirect_uri: str,
code_challenge: str,
code_challenge_method: str,
user_id: int,
ttl_seconds: int = 600,
) -> str:
code = secrets.token_urlsafe(32)
expires_at = time.time() + ttl_seconds
with self.connection() as conn:
conn.execute(
"""
INSERT INTO oauth_codes
(code, client_id, redirect_uri, code_challenge, code_challenge_method, user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(code, client_id, redirect_uri, code_challenge, code_challenge_method, user_id, expires_at),
)
return code
def consume_oauth_code(self, code: str) -> sqlite3.Row | None:
now = time.time()
with self.connection() as conn:
row = conn.execute(
"SELECT * FROM oauth_codes WHERE code = ? AND expires_at > ?",
(code, now),
).fetchone()
if row is None:
return None
conn.execute("DELETE FROM oauth_codes WHERE code = ?", (code,))
return row
def create_access_token(self, client_id: str, user_id: int, ttl_seconds: int = 86400 * 7) -> str:
token = secrets.token_urlsafe(48)
expires_at = time.time() + ttl_seconds
with self.connection() as conn:
conn.execute(
"INSERT INTO access_tokens (token, client_id, user_id, expires_at) VALUES (?, ?, ?, ?)",
(token, client_id, user_id, expires_at),
)
return token
def get_access_token(self, token: str) -> sqlite3.Row | None:
now = time.time()
with self.connection() as conn:
return conn.execute(
"SELECT * FROM access_tokens WHERE token = ? AND expires_at > ?",
(token, now),
).fetchone()
def save_oidc_state(self, state: str, next_url: str, ttl_seconds: int = 600) -> None:
expires_at = time.time() + ttl_seconds
with self.connection() as conn:
conn.execute(
"INSERT INTO oidc_states (state, next_url, expires_at) VALUES (?, ?, ?)",
(state, next_url, expires_at),
)
def pop_oidc_state(self, state: str) -> str | None:
now = time.time()
with self.connection() as conn:
row = conn.execute(
"SELECT next_url FROM oidc_states WHERE state = ? AND expires_at > ?",
(state, now),
).fetchone()
if row is None:
return None
conn.execute("DELETE FROM oidc_states WHERE state = ?", (state,))
return row["next_url"]
# ---- dynamic client registration (RFC 7591 / 7592) ----
@staticmethod
def _hash(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def register_client(
self, client_name: str, redirect_uris: list[str], max_clients: int
) -> tuple[str, str, int] | None:
client_id = "dcr-" + secrets.token_urlsafe(24)
reg_token = secrets.token_urlsafe(32)
now = time.time()
with self.connection() as conn:
(count,) = conn.execute("SELECT COUNT(*) FROM oauth_clients").fetchone()
if count >= max_clients:
return None
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris, registration_token_hash, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(client_id, client_name, json.dumps(redirect_uris), self._hash(reg_token), now),
)
return client_id, reg_token, int(now)
def get_client(self, client_id: str) -> sqlite3.Row | None:
with self.connection() as conn:
return conn.execute(
"SELECT * FROM oauth_clients WHERE client_id = ?", (client_id,)
).fetchone()
def get_client_redirect_uris(self, client_id: str) -> list[str] | None:
row = self.get_client(client_id)
if row is None:
return None
return list(json.loads(row["redirect_uris"]))
def check_registration_token(self, client_id: str, token: str) -> bool:
row = self.get_client(client_id)
if row is None or not token:
return False
return hmac.compare_digest(row["registration_token_hash"], self._hash(token))
def delete_client(self, client_id: str) -> None:
with self.connection() as conn:
conn.execute("DELETE FROM oauth_clients WHERE client_id = ?", (client_id,))
conn.execute("DELETE FROM access_tokens WHERE client_id = ?", (client_id,))
conn.execute("DELETE FROM oauth_codes WHERE client_id = ?", (client_id,))
# ---- upstream (Gitea) OAuth tokens ----
def set_upstream_tokens(
self, user_id: int, enc_access: str, enc_refresh: str | None, expires_at: float
) -> None:
with self.connection() as conn:
conn.execute(
"UPDATE users SET upstream_access = ?, upstream_refresh = COALESCE(?, upstream_refresh),"
" upstream_expires_at = ? WHERE id = ?",
(enc_access, enc_refresh, expires_at, user_id),
)
def get_upstream_tokens(self, user_id: int) -> sqlite3.Row | None:
with self.connection() as conn:
return conn.execute(
"SELECT upstream_access, upstream_refresh, upstream_expires_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
+55
View File
@@ -0,0 +1,55 @@
"""RFC 7591 dynamic client registration helpers (G3X, 2026-10-19)."""
from __future__ import annotations
import re
import time
from collections import defaultdict, deque
from urllib.parse import urlparse
from fastapi import Request
_SCHEME_RE = re.compile(r"^[a-z][a-z0-9+.\-]*$")
_BAD_SCHEMES = {"javascript", "data", "file", "vbscript", "about", "blob", "ftp", "ws", "wss"}
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
SUPPORTED_GRANTS = {"authorization_code", "refresh_token"}
def valid_redirect_uri(uri: object) -> bool:
"""https URLs, http loopback URLs, or private app schemes (cursor://...)."""
if not isinstance(uri, str) or not uri or len(uri) > 2000 or "*" in uri:
return False
if any(c.isspace() or ord(c) < 32 for c in uri):
return False
p = urlparse(uri)
if p.fragment or not p.scheme:
return False
if p.scheme == "https":
return bool(p.hostname)
if p.scheme == "http":
return p.hostname in _LOOPBACK
return bool(_SCHEME_RE.match(p.scheme)) and p.scheme not in _BAD_SCHEMES
class RateLimiter:
def __init__(self, max_events: int, window_seconds: float) -> None:
self.max_events = max_events
self.window = window_seconds
self._events: dict[str, deque[float]] = defaultdict(deque)
def hit(self, key: str) -> bool:
"""Record an event; return True when the limit is exceeded."""
now = time.time()
q = self._events[key]
while q and q[0] < now - self.window:
q.popleft()
if len(q) >= self.max_events:
return True
q.append(now)
return False
def client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else "unknown"
+502
View File
@@ -0,0 +1,502 @@
from __future__ import annotations
import asyncio
import secrets
import time
from typing import Any
from urllib.parse import urlencode
import httpx
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.sessions import SessionMiddleware
from app.config import Settings, get_settings
from app.crypto import decrypt_token, encrypt_token
from app.db import Database
from app.dcr import SUPPORTED_GRANTS, RateLimiter, client_ip, valid_redirect_uri
from app.pkce import verify_pkce_s256
app = FastAPI(title="Gitea MCP OAuth Bridge")
_db: Database | None = None
def db() -> Database:
global _db
if _db is None:
_db = Database(get_settings().data_dir)
return _db
def _install_session_middleware() -> None:
settings = get_settings()
app.add_middleware(SessionMiddleware, secret_key=settings.session_secret, same_site="lax")
_install_session_middleware()
def authorization_server_metadata(settings: Settings) -> dict[str, Any]:
base = settings.issuer
return {
"issuer": base,
"authorization_endpoint": f"{base}/authorize",
"token_endpoint": f"{base}/token",
"registration_endpoint": f"{base}/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["openid"],
}
def protected_resource_metadata(settings: Settings, resource: str) -> dict[str, Any]:
return {
"resource": resource,
"authorization_servers": [settings.issuer],
"bearer_methods_supported": ["header"],
}
def validate_mcp_client(settings: Settings, client_id: str, redirect_uri: str) -> bool:
allowed = settings.allowed_clients.get(client_id)
if allowed is not None:
return redirect_uri in allowed
if not settings.dcr_enabled:
return False
registered = db().get_client_redirect_uris(client_id)
return registered is not None and redirect_uri in registered
def session_user_id(request: Request) -> int | None:
raw = request.session.get("user_id")
if raw is None:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
def mcp_unauthorized_response(settings: Settings) -> JSONResponse:
resource_meta = protected_resource_metadata(settings, settings.mcp_resource_url)
www = f'Bearer realm="mcp", resource_metadata="{settings.issuer}/.well-known/oauth-protected-resource/mcp"'
return JSONResponse(
status_code=401,
content={
"error": "unauthorized",
"message": "Valid OAuth access token required.",
"resource_metadata": resource_meta,
},
headers={"WWW-Authenticate": www},
)
@app.get("/health")
async def health() -> dict[str, bool]:
return {"ok": True}
@app.get("/.well-known/oauth-authorization-server")
@app.get("/mcp/.well-known/oauth-authorization-server")
async def well_known_as(settings: Settings = Depends(get_settings)) -> dict[str, Any]:
return authorization_server_metadata(settings)
@app.get("/.well-known/oauth-protected-resource")
async def well_known_pr(settings: Settings = Depends(get_settings)) -> dict[str, Any]:
return protected_resource_metadata(settings, settings.issuer)
@app.get("/.well-known/oauth-protected-resource/mcp")
async def well_known_pr_mcp(settings: Settings = Depends(get_settings)) -> dict[str, Any]:
return protected_resource_metadata(settings, settings.mcp_resource_url)
_dcr_limiter = RateLimiter(max_events=30, window_seconds=600)
def _oauth_error(error: str, description: str, status: int = 400) -> JSONResponse:
return JSONResponse(status_code=status, content={"error": error, "error_description": description})
def _client_metadata(settings: Settings, row: Any) -> dict[str, Any]:
import json
return {
"client_id": row["client_id"],
"client_id_issued_at": int(row["created_at"]),
"client_name": row["client_name"] or "",
"redirect_uris": json.loads(row["redirect_uris"]),
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"registration_client_uri": f"{settings.issuer}/register/{row['client_id']}",
}
@app.post("/register")
async def register_client(request: Request, settings: Settings = Depends(get_settings)) -> JSONResponse:
"""RFC 7591 dynamic client registration for public PKCE clients."""
if not settings.dcr_enabled:
return _oauth_error(
"registration_not_supported",
"Register MCP clients via ALLOWED_MCP_CLIENTS; dynamic registration is disabled.",
)
if _dcr_limiter.hit(client_ip(request)):
return _oauth_error("too_many_requests", "Rate limit exceeded for client registration", 429)
try:
body = await request.json()
except Exception:
body = None
if not isinstance(body, dict):
return _oauth_error("invalid_client_metadata", "Request body must be a JSON object")
uris = body.get("redirect_uris")
if not isinstance(uris, list) or not uris or len(uris) > 20:
return _oauth_error("invalid_redirect_uri", "redirect_uris must be a non-empty list")
bad = [u for u in uris if not valid_redirect_uri(u)]
if bad:
return _oauth_error(
"invalid_redirect_uri",
"Allowed redirect URIs: https://..., http://localhost or 127.0.0.1, or an app scheme "
f"such as cursor://... Rejected: {bad[:3]}",
)
grants = body.get("grant_types") or ["authorization_code"]
if not isinstance(grants, list) or "authorization_code" not in grants or not set(grants) <= SUPPORTED_GRANTS:
return _oauth_error("invalid_client_metadata", "grant_types must include authorization_code")
rtypes = body.get("response_types") or ["code"]
if rtypes != ["code"]:
return _oauth_error("invalid_client_metadata", 'Only response_types ["code"] is supported')
# Public clients only: any requested auth method is answered with "none"
# (RFC 7591 lets the server replace requested values); PKCE is mandatory.
name = str(body.get("client_name") or "")[:200]
created = db().register_client(name, uris, settings.dcr_max_clients)
if created is None:
return _oauth_error("invalid_client_metadata", "Client registration limit reached")
client_id, reg_token, issued_at = created
content = _client_metadata(settings, db().get_client(client_id))
content["registration_access_token"] = reg_token
return JSONResponse(status_code=201, content=content, headers={"Cache-Control": "no-store"})
def _registration_auth(request: Request, client_id: str) -> bool:
auth = request.headers.get("authorization", "")
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
return db().check_registration_token(client_id, token)
@app.get("/register/{client_id}")
async def read_client(client_id: str, request: Request, settings: Settings = Depends(get_settings)) -> JSONResponse:
if not _registration_auth(request, client_id):
return _oauth_error("invalid_token", "Invalid registration access token", 401)
return JSONResponse(content=_client_metadata(settings, db().get_client(client_id)))
@app.delete("/register/{client_id}")
async def delete_client(client_id: str, request: Request) -> Response:
if not _registration_auth(request, client_id):
return _oauth_error("invalid_token", "Invalid registration access token", 401)
db().delete_client(client_id)
return Response(status_code=204)
@app.get("/authorize")
async def authorize(
request: Request,
response_type: str = Query(...),
client_id: str = Query(...),
redirect_uri: str = Query(...),
code_challenge: str = Query(...),
code_challenge_method: str = Query("S256"),
state: str | None = Query(None),
settings: Settings = Depends(get_settings),
) -> Response:
if response_type != "code":
raise HTTPException(status_code=400, detail="unsupported response_type")
if code_challenge_method != "S256":
raise HTTPException(status_code=400, detail="PKCE S256 required")
if not validate_mcp_client(settings, client_id, redirect_uri):
raise HTTPException(status_code=400, detail="invalid client_id or redirect_uri")
user_id = session_user_id(request)
if user_id is None:
query = request.url.query
next_url = f"/authorize?{query}" if query else "/authorize"
return RedirectResponse(url=f"/oidc/login?{urlencode({'next': next_url})}", status_code=302)
user = db().get_user_by_id(user_id)
if user is None:
raise HTTPException(status_code=401, detail="Login required")
tokens = db().get_upstream_tokens(user.id)
if tokens is None or not tokens["upstream_access"]:
query = request.url.query
next_url = f"/authorize?{query}" if query else "/authorize"
return RedirectResponse(url=f"/oidc/login?{urlencode({'next': next_url})}", status_code=302)
code = db().create_oauth_code(
client_id=client_id,
redirect_uri=redirect_uri,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
user_id=user.id,
)
params = {"code": code}
if state:
params["state"] = state
location = f"{redirect_uri}?{urlencode(params)}"
return RedirectResponse(url=location, status_code=302)
@app.post("/token")
async def token(
request: Request,
settings: Settings = Depends(get_settings),
) -> JSONResponse:
form = await request.form()
grant_type = form.get("grant_type")
if grant_type != "authorization_code":
raise HTTPException(status_code=400, detail="unsupported grant_type")
code = form.get("code")
redirect_uri = form.get("redirect_uri")
client_id = form.get("client_id")
code_verifier = form.get("code_verifier")
if not all(isinstance(x, str) for x in (code, redirect_uri, client_id, code_verifier)):
raise HTTPException(status_code=400, detail="invalid request")
assert isinstance(code, str)
assert isinstance(redirect_uri, str)
assert isinstance(client_id, str)
assert isinstance(code_verifier, str)
if not validate_mcp_client(settings, client_id, redirect_uri):
raise HTTPException(status_code=400, detail="invalid client_id or redirect_uri")
row = db().consume_oauth_code(code)
if row is None:
raise HTTPException(status_code=400, detail="invalid or expired code")
if row["client_id"] != client_id or row["redirect_uri"] != redirect_uri:
raise HTTPException(status_code=400, detail="client or redirect mismatch")
if not verify_pkce_s256(code_verifier, row["code_challenge"]):
raise HTTPException(status_code=400, detail="invalid code_verifier")
access_token = db().create_access_token(client_id=client_id, user_id=int(row["user_id"]))
return JSONResponse(
content={
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 604800,
}
)
def _oidc_discovery(settings: Settings) -> dict[str, Any]:
issuer = settings.oidc_issuer.rstrip("/")
with httpx.Client(timeout=15.0) as client:
resp = client.get(f"{issuer}/.well-known/openid-configuration")
resp.raise_for_status()
return resp.json()
@app.get("/oidc/login")
async def oidc_login(
request: Request,
next: str = Query("/"),
settings: Settings = Depends(get_settings),
) -> RedirectResponse:
state = secrets.token_urlsafe(24)
db().save_oidc_state(state, next)
discovery = _oidc_discovery(settings)
auth_endpoint = discovery["authorization_endpoint"]
redirect_uri = f"{settings.issuer}/oidc/callback"
params = {
"client_id": settings.oidc_client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"scope": settings.oidc_scopes,
"state": state,
}
return RedirectResponse(url=f"{auth_endpoint}?{urlencode(params)}", status_code=302)
@app.get("/oidc/callback")
async def oidc_callback(
request: Request,
code: str | None = Query(None),
state: str | None = Query(None),
settings: Settings = Depends(get_settings),
) -> RedirectResponse:
if not code or not state:
raise HTTPException(status_code=400, detail="missing code or state")
next_url = db().pop_oidc_state(state)
if next_url is None:
raise HTTPException(status_code=400, detail="invalid or expired state")
discovery = _oidc_discovery(settings)
token_endpoint = discovery["token_endpoint"]
redirect_uri = f"{settings.issuer}/oidc/callback"
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": settings.oidc_client_id,
"client_secret": settings.oidc_client_secret,
}
with httpx.Client(timeout=15.0) as client:
token_resp = client.post(token_endpoint, data=data)
token_resp.raise_for_status()
tokens = token_resp.json()
id_token = tokens.get("id_token")
access = tokens.get("access_token")
sub = None
email = None
if id_token:
import base64
import json
payload_b64 = id_token.split(".")[1]
padding = "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
sub = payload.get("sub")
email = payload.get("email")
if not sub and access:
userinfo = discovery.get("userinfo_endpoint")
if userinfo:
with httpx.Client(timeout=15.0) as client:
ui = client.get(userinfo, headers={"Authorization": f"Bearer {access}"})
if ui.status_code == 200:
body = ui.json()
sub = body.get("sub")
email = body.get("email")
if not sub:
raise HTTPException(status_code=400, detail="could not resolve OIDC subject")
user = db().upsert_user(oidc_sub=str(sub), email=email)
request.session["user_id"] = user.id
if not access:
raise HTTPException(status_code=400, detail="upstream returned no access token")
_store_upstream_tokens(settings, user.id, tokens)
return RedirectResponse(url=next_url, status_code=302)
def _store_upstream_tokens(settings: Settings, user_id: int, tokens: dict[str, Any]) -> None:
key = settings.token_encryption_key
refresh = tokens.get("refresh_token")
expires_in = float(tokens.get("expires_in") or 3600)
db().set_upstream_tokens(
user_id,
encrypt_token(key, tokens["access_token"]),
encrypt_token(key, refresh) if refresh else None,
time.time() + expires_in,
)
_refresh_locks: dict[int, asyncio.Lock] = {}
async def _upstream_access_token(settings: Settings, user_id: int) -> str | None:
"""Current upstream access token for the user, refreshing it when it is about to expire."""
key = settings.token_encryption_key
def fresh() -> str | None:
row = db().get_upstream_tokens(user_id)
if row is None or not row["upstream_access"]:
return None
if (row["upstream_expires_at"] or 0) > time.time() + 60:
return decrypt_token(key, row["upstream_access"])
return None
token = fresh()
if token:
return token
lock = _refresh_locks.setdefault(user_id, asyncio.Lock())
async with lock:
token = fresh()
if token:
return token
row = db().get_upstream_tokens(user_id)
refresh = decrypt_token(key, row["upstream_refresh"]) if row is not None and row["upstream_refresh"] else None
if not refresh:
return None
discovery = _oidc_discovery(settings)
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
discovery["token_endpoint"],
data={
"grant_type": "refresh_token",
"refresh_token": refresh,
"client_id": settings.oidc_client_id,
"client_secret": settings.oidc_client_secret,
},
)
if resp.status_code != 200:
return None
tokens = resp.json()
if not tokens.get("access_token"):
return None
_store_upstream_tokens(settings, user_id, tokens)
return tokens["access_token"]
_FORWARD_REQUEST_HEADERS = ("mcp-session-id", "mcp-protocol-version", "last-event-id")
_FORWARD_RESPONSE_HEADERS = ("mcp-session-id",)
async def _proxy_mcp(request: Request, settings: Settings) -> Response:
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
return mcp_unauthorized_response(settings)
token = auth[7:].strip()
row = db().get_access_token(token)
if row is None:
if not (settings.passthrough_unknown_tokens and token):
return mcp_unauthorized_response(settings)
upstream_auth = f"Bearer {token}"
else:
upstream_token = await _upstream_access_token(settings, int(row["user_id"]))
if not upstream_token:
return mcp_unauthorized_response(settings)
upstream_auth = f"Bearer {upstream_token}"
body = await request.body()
headers = {
"Authorization": upstream_auth,
"Content-Type": request.headers.get("content-type", "application/json"),
"Accept": request.headers.get("accept", "application/json"),
}
for name in _FORWARD_REQUEST_HEADERS:
value = request.headers.get(name)
if value:
headers[name] = value
if settings.upstream_host_header:
headers["Host"] = settings.upstream_host_header
async with httpx.AsyncClient(timeout=120.0) as client:
upstream = await client.request(
method=request.method,
url=settings.upstream_mcp_url,
content=body,
headers=headers,
)
if upstream.status_code == 401:
return mcp_unauthorized_response(settings)
out_headers = {"Content-Type": upstream.headers.get("content-type", "application/json")}
for name in _FORWARD_RESPONSE_HEADERS:
value = upstream.headers.get(name)
if isinstance(value, str) and value:
out_headers[name] = value
return Response(content=upstream.content, status_code=upstream.status_code, headers=out_headers)
@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
async def mcp_endpoint(request: Request, settings: Settings = Depends(get_settings)) -> Response:
if request.method == "GET":
return Response(status_code=405, headers={"Allow": "POST, DELETE"})
return await _proxy_mcp(request, settings)
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
import base64
import hashlib
def verify_pkce_s256(code_verifier: str, code_challenge: str) -> bool:
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return computed == code_challenge.rstrip("=")