Gitea MCP OAuth 브리지 (DCR, Gitea 로그인)
Gitea에 동적 클라이언트 등록이 없어 MCP 클라이언트가 URL만으로 OAuth 연결을 못 하는 문제를 이 브리지가 인가 서버 역할을 대신해 해결한다. G3X의 plane-mcp-oauth-bridge(upstream_oauth 모드)에서 가져와 Gitea 전용으로 정리했다: Plane PAT 연결 페이지·워크스페이스 헤더·bridge_mode 분기를 없앴다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
916d149ab5
18 files changed
+1494
No files matched your search
@@ -0,0 +1,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()
|
||||
Reference in new issue
Block a user