From 205b978b89fd384a2d2714d6bc8ac1c76edf008a Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Mon, 30 Mar 2026 09:45:04 +0000 Subject: [PATCH] feat: add OAuth client credentials auth to MCP server - OAuth 2.0 discovery at /.well-known/oauth-authorization-server - Token endpoint at /token (client_credentials grant) - Bearer token middleware on /mcp (all MCP requests require auth) - Health, ingest, and OAuth endpoints remain public - Tokens expire after 1 hour, stored hashed in memory Co-Authored-By: Claude Opus 4.6 (1M context) --- credentials.example | 4 ++ mcp_bridge/__main__.py | 64 ++++++++++++++++++-- mcp_bridge/auth.py | 134 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 mcp_bridge/auth.py diff --git a/credentials.example b/credentials.example index e076b10..3cf1850 100644 --- a/credentials.example +++ b/credentials.example @@ -11,3 +11,7 @@ GROUP_CHAT_ID= # (Optional) Bot ID of the existing homelab bot, for sender classification # Find it: https://api.telegram.org/bot/getMe HOMELAB_BOT_ID=8521598773 + +# OAuth client credentials for MCP auth (generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))") +OAUTH_CLIENT_ID= +OAUTH_CLIENT_SECRET= diff --git a/mcp_bridge/__main__.py b/mcp_bridge/__main__.py index 76bc366..4a90eec 100644 --- a/mcp_bridge/__main__.py +++ b/mcp_bridge/__main__.py @@ -4,10 +4,15 @@ import asyncio import logging import signal +from starlette.middleware import Middleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + from .config import MCP_HOST, MCP_PORT, MEDIA_DIR, DB_PATH from .db import Database from .telegram_bot import BridgeBot from .mcp_server import mcp, init as init_mcp, custom_routes +from .auth import auth_routes, validate_bearer_token logging.basicConfig( level=logging.INFO, @@ -16,12 +21,53 @@ logging.basicConfig( ) logger = logging.getLogger("mcp_bridge") +# Paths that don't require auth +PUBLIC_PATHS = { + "/.well-known/oauth-authorization-server", + "/token", + "/api/health", + "/api/ingest", # Local-only, not exposed via NPM +} + + +class AuthMiddleware: + """ASGI middleware that validates Bearer tokens on protected endpoints.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + + path = scope.get("path", "") + + # Skip auth for public paths + if path in PUBLIC_PATHS: + return await self.app(scope, receive, send) + + # Check Authorization header + headers = dict(scope.get("headers", [])) + auth_header = headers.get(b"authorization", b"").decode() + + if auth_header.startswith("Bearer "): + token = auth_header[7:] + if validate_bearer_token(token): + return await self.app(scope, receive, send) + + # Reject — send 401 + response = JSONResponse( + {"error": "unauthorized", "error_description": "Valid Bearer token required"}, + status_code=401, + headers={"WWW-Authenticate": 'Bearer realm="mcp"'}, + ) + await response(scope, receive, send) + async def run_telegram_bot(bot: BridgeBot): """Run the telegram bot polling loop.""" app = bot.build_application() await app.initialize() - # post_init isn't called automatically when we manually start await bot._post_init(app) await app.start() updater = app.updater @@ -39,15 +85,21 @@ async def run_telegram_bot(bot: BridgeBot): async def run_mcp_server(): - """Run the FastMCP HTTP server with custom API routes.""" + """Run the FastMCP HTTP server with OAuth auth and custom routes.""" import uvicorn - # Get the FastMCP Starlette app and add our custom routes to it + # Get the FastMCP Starlette app mcp_app = mcp.http_app() - mcp_app.routes.extend(custom_routes) - logger.info(f"MCP server starting on {MCP_HOST}:{MCP_PORT}") - config = uvicorn.Config(mcp_app, host=MCP_HOST, port=MCP_PORT, log_level="info") + # Add custom routes (API + OAuth) + mcp_app.routes.extend(custom_routes) + mcp_app.routes.extend(auth_routes) + + # Wrap with auth middleware + authed_app = AuthMiddleware(mcp_app) + + logger.info(f"MCP server starting on {MCP_HOST}:{MCP_PORT} (OAuth enabled)") + config = uvicorn.Config(authed_app, host=MCP_HOST, port=MCP_PORT, log_level="info") server = uvicorn.Server(config) await server.serve() diff --git a/mcp_bridge/auth.py b/mcp_bridge/auth.py new file mode 100644 index 0000000..648708f --- /dev/null +++ b/mcp_bridge/auth.py @@ -0,0 +1,134 @@ +"""OAuth 2.0 client credentials auth for MCP server.""" + +import hashlib +import json +import logging +import secrets +import time + +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from .config import load_credentials + +logger = logging.getLogger(__name__) + +# In-memory token store: token_hash -> expiry timestamp +_active_tokens: dict[str, float] = {} +TOKEN_LIFETIME = 3600 # 1 hour + + +def _get_oauth_credentials() -> tuple[str, str]: + """Load OAuth client_id and client_secret from credentials file.""" + creds = load_credentials() + client_id = creds.get("OAUTH_CLIENT_ID", "") + client_secret = creds.get("OAUTH_CLIENT_SECRET", "") + if not client_id or not client_secret: + raise RuntimeError("OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET must be set in credentials") + return client_id, client_secret + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def _cleanup_expired(): + """Remove expired tokens.""" + now = time.time() + expired = [h for h, exp in _active_tokens.items() if exp < now] + for h in expired: + del _active_tokens[h] + + +def validate_bearer_token(token: str) -> bool: + """Check if a bearer token is valid and not expired.""" + _cleanup_expired() + token_hash = _hash_token(token) + return token_hash in _active_tokens and _active_tokens[token_hash] > time.time() + + +async def token_endpoint(request: Request) -> JSONResponse: + """OAuth 2.0 token endpoint (client_credentials grant). + + POST /token + Content-Type: application/x-www-form-urlencoded + + grant_type=client_credentials&client_id=...&client_secret=... + """ + try: + # Accept both form-encoded and JSON + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + data = await request.json() + else: + form = await request.form() + data = dict(form) + except Exception: + return JSONResponse( + {"error": "invalid_request", "error_description": "Could not parse request body"}, + status_code=400, + ) + + grant_type = data.get("grant_type") + if grant_type != "client_credentials": + return JSONResponse( + {"error": "unsupported_grant_type", "error_description": "Only client_credentials is supported"}, + status_code=400, + ) + + client_id = data.get("client_id", "") + client_secret = data.get("client_secret", "") + + try: + expected_id, expected_secret = _get_oauth_credentials() + except RuntimeError: + logger.error("OAuth credentials not configured") + return JSONResponse( + {"error": "server_error", "error_description": "Auth not configured"}, + status_code=500, + ) + + if not secrets.compare_digest(client_id, expected_id) or \ + not secrets.compare_digest(client_secret, expected_secret): + logger.warning(f"OAuth auth failed from {request.client.host}") + return JSONResponse( + {"error": "invalid_client", "error_description": "Invalid client credentials"}, + status_code=401, + ) + + # Issue token + access_token = secrets.token_urlsafe(48) + _active_tokens[_hash_token(access_token)] = time.time() + TOKEN_LIFETIME + _cleanup_expired() + + logger.info(f"OAuth token issued to {request.client.host}") + return JSONResponse({ + "access_token": access_token, + "token_type": "Bearer", + "expires_in": TOKEN_LIFETIME, + }) + + +async def oauth_metadata(request: Request) -> JSONResponse: + """OAuth 2.0 Authorization Server Metadata (RFC 8414). + + GET /.well-known/oauth-authorization-server + """ + # Build base URL from request + base = str(request.base_url).rstrip("/") + return JSONResponse({ + "issuer": base, + "token_endpoint": f"{base}/token", + "token_endpoint_auth_methods_supported": ["client_secret_post"], + "grant_types_supported": ["client_credentials"], + "response_types_supported": [], + "scopes_supported": ["mcp"], + }) + + +# Routes to add to the app +auth_routes = [ + Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]), + Route("/token", token_endpoint, methods=["POST"]), +]