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>
260 lines
8.7 KiB
Python
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()
|