Files
gitea-mcp-oauth-bridge/app/db.py
T
ysj7705andClaude Opus 5.5 916d149ab5 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>
2026-10-22 18:20:02 +09:00

260 lines
8.7 KiB
Python

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()