Fase 2: OAuth 2.1 + PKCE embutido com registro dinamico, mantendo o bearer estatico
Pablo Murad pablo@pablomurad.com
Sat, 08 Aug 2026 15:20:25 -0300
5 files changed,
245 insertions(+),
8 deletions(-)
M
.env.example
→
.env.example
@@ -12,5 +12,11 @@ # Coletor de fundo.
POLL_INTERVAL_MIN=30 RETENTION_DAYS=90 +# OAuth (para conectar pelo claude.ai web/celular). Defina a URL publica do +# servidor e uma senha para o login do /authorize. Vazio = OAuth desligado, +# so o bearer acima (Claude Code/Desktop). +NEWS_MCP_ISSUER_URL= +NEWS_MCP_AUTH_PASSWORD= + # DB_PATH e FEEDS_PATH são definidos pelo docker-compose (volume /data e # /app/feeds.json). Rodando fora do Docker, o padrão é ao lado do server.py.
M
README.md
→
README.md
@@ -27,13 +27,15 @@ (edite o .env e defina NEWS_MCP_TOKEN)
mkdir -p data docker compose up -d --build -A porta fica presa em 127.0.0.1:17631, atras de um proxy reverso (nginx) com HTTPS. O banco fica em ./data/news.db e persiste. Editar o feeds.json vale na proxima coleta, sem rebuild. +A porta fica presa em 127.0.0.1:17631, atras de um proxy reverso (nginx) com HTTPS. O nginx deve repassar todas as rotas (proxy_pass para 127.0.0.1:17631), nao so /mcp, porque o OAuth usa /authorize, /token, /register e /.well-known. O banco fica em ./data/news.db e persiste. Editar o feeds.json vale na proxima coleta, sem rebuild. -Configuracao pelo .env: NEWS_MCP_TOKEN, NEWS_MCP_PORT, POLL_INTERVAL_MIN, RETENTION_DAYS. +Configuracao pelo .env: NEWS_MCP_TOKEN, NEWS_MCP_PORT, POLL_INTERVAL_MIN, RETENTION_DAYS, NEWS_MCP_ISSUER_URL, NEWS_MCP_AUTH_PASSWORD. Trocar as fontes: edite o feeds.json. Cada fonte tem id, name, url e category. -Conectar no cliente MCP (transporte http com bearer token): +Conectar no Claude Code / Desktop (transporte http com bearer token): news https://mcpnews.grupomurad.net/mcp header: Authorization: Bearer SEU_TOKEN + +Conectar no claude.ai (web/celular) via OAuth: defina NEWS_MCP_ISSUER_URL (ex: https://mcpnews.grupomurad.net) e NEWS_MCP_AUTH_PASSWORD no .env. No claude.ai, adicione um conector personalizado com a URL https://mcpnews.grupomurad.net/mcp; o navegador vai pedir a senha do /authorize para autorizar.
M
config.py
→
config.py
@@ -15,3 +15,6 @@ TOKEN = os.environ.get("NEWS_MCP_TOKEN", "")
POLL_INTERVAL_MIN = int(os.environ.get("POLL_INTERVAL_MIN", "30")) RETENTION_DAYS = int(os.environ.get("RETENTION_DAYS", "90")) + +ISSUER_URL = os.environ.get("NEWS_MCP_ISSUER_URL", "").rstrip("/") +AUTH_PASSWORD = os.environ.get("NEWS_MCP_AUTH_PASSWORD", "")
A
oauth.py
@@ -0,0 +1,178 @@
+import time +import sqlite3 +import secrets +from typing import Optional, List + +from mcp.server.auth.provider import ( + OAuthAuthorizationServerProvider, + AuthorizationParams, + AuthorizationCode, + AccessToken, + RefreshToken, + construct_redirect_uri, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +import config + +_CODE_TTL = 300 +_ACCESS_TTL = 3600 +_REFRESH_TTL = 30 * 24 * 3600 +_SCOPES = ["news"] + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS oauth_clients ( + client_id TEXT PRIMARY KEY, + data TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS oauth_codes ( + code TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + data TEXT NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS oauth_access ( + token TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + scopes TEXT NOT NULL, + subject TEXT, + expires_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS oauth_refresh ( + token TEXT PRIMARY KEY, + client_id TEXT NOT NULL, + scopes TEXT NOT NULL, + subject TEXT, + expires_at INTEGER NOT NULL +); +""" + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(config.DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL;") + return conn + + +def init_oauth() -> None: + config.DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with _connect() as conn: + conn.executescript(_SCHEMA) + + +class NewsOAuthProvider(OAuthAuthorizationServerProvider): + async def get_client(self, client_id: str) -> Optional[OAuthClientInformationFull]: + with _connect() as conn: + row = conn.execute( + "SELECT data FROM oauth_clients WHERE client_id = ?", (client_id,) + ).fetchone() + if not row: + return None + return OAuthClientInformationFull.model_validate_json(row["data"]) + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + with _connect() as conn: + conn.execute( + "INSERT OR REPLACE INTO oauth_clients (client_id, data) VALUES (?, ?)", + (client_info.client_id, client_info.model_dump_json()), + ) + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + code = secrets.token_urlsafe(32) + auth_code = AuthorizationCode( + code=code, + scopes=params.scopes or _SCOPES, + expires_at=time.time() + _CODE_TTL, + client_id=client.client_id, + code_challenge=params.code_challenge, + redirect_uri=params.redirect_uri, + redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, + resource=params.resource, + subject="owner", + ) + with _connect() as conn: + conn.execute( + "INSERT INTO oauth_codes (code, client_id, data, expires_at) VALUES (?, ?, ?, ?)", + (code, client.client_id, auth_code.model_dump_json(), int(auth_code.expires_at)), + ) + return construct_redirect_uri(str(params.redirect_uri), code=code, state=params.state) + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> Optional[AuthorizationCode]: + with _connect() as conn: + row = conn.execute( + "SELECT data, expires_at FROM oauth_codes WHERE code = ? AND client_id = ?", + (authorization_code, client.client_id), + ).fetchone() + if not row or row["expires_at"] < time.time(): + return None + return AuthorizationCode.model_validate_json(row["data"]) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + with _connect() as conn: + conn.execute("DELETE FROM oauth_codes WHERE code = ?", (authorization_code.code,)) + return self._issue(client.client_id, authorization_code.scopes, authorization_code.subject) + + async def load_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: str + ) -> Optional[RefreshToken]: + with _connect() as conn: + row = conn.execute( + "SELECT scopes, expires_at FROM oauth_refresh WHERE token = ? AND client_id = ?", + (refresh_token, client.client_id), + ).fetchone() + if not row or row["expires_at"] < time.time(): + return None + return RefreshToken( + token=refresh_token, + client_id=client.client_id, + scopes=row["scopes"].split(), + expires_at=row["expires_at"], + ) + + async def exchange_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: List[str] + ) -> OAuthToken: + with _connect() as conn: + conn.execute("DELETE FROM oauth_refresh WHERE token = ?", (refresh_token.token,)) + return self._issue(client.client_id, scopes or refresh_token.scopes, "owner") + + async def load_access_token(self, token: str) -> Optional[AccessToken]: + if config.TOKEN and secrets.compare_digest(token, config.TOKEN): + return AccessToken(token=token, client_id="owner-static", scopes=_SCOPES, + expires_at=None, subject="owner") + with _connect() as conn: + row = conn.execute( + "SELECT client_id, scopes, subject, expires_at FROM oauth_access WHERE token = ?", + (token,), + ).fetchone() + if not row or row["expires_at"] < time.time(): + return None + return AccessToken(token=token, client_id=row["client_id"], scopes=row["scopes"].split(), + expires_at=row["expires_at"], subject=row["subject"]) + + async def revoke_token(self, token) -> None: + with _connect() as conn: + conn.execute("DELETE FROM oauth_access WHERE token = ?", (token.token,)) + conn.execute("DELETE FROM oauth_refresh WHERE token = ?", (token.token,)) + + def _issue(self, client_id: str, scopes: List[str], subject: Optional[str]) -> OAuthToken: + access = secrets.token_urlsafe(32) + refresh = secrets.token_urlsafe(32) + now = int(time.time()) + scope_str = " ".join(scopes) + with _connect() as conn: + conn.execute( + "INSERT INTO oauth_access (token, client_id, scopes, subject, expires_at) VALUES (?, ?, ?, ?, ?)", + (access, client_id, scope_str, subject, now + _ACCESS_TTL), + ) + conn.execute( + "INSERT INTO oauth_refresh (token, client_id, scopes, subject, expires_at) VALUES (?, ?, ?, ?, ?)", + (refresh, client_id, scope_str, subject, now + _REFRESH_TTL), + ) + return OAuthToken(access_token=access, token_type="Bearer", expires_in=_ACCESS_TTL, + refresh_token=refresh, scope=scope_str)
M
server.py
→
server.py
@@ -1,15 +1,19 @@
import asyncio +import base64 import logging +import secrets from contextlib import asynccontextmanager import uvicorn from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request -from starlette.responses import JSONResponse +from starlette.responses import JSONResponse, Response from mcp.server.fastmcp import FastMCP +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions import config import db +import oauth from feeds import load_feeds, categories, filter_category from formatting import format_items from models import LatestInput, SearchInput@@ -21,10 +25,14 @@ format="%(asctime)s %(name)s %(levelname)s %(message)s",
) log = logging.getLogger("news_mcp") +_provider = oauth.NewsOAuthProvider() if config.ISSUER_URL else None + @asynccontextmanager async def lifespan(_server: FastMCP): db.init_db() + if _provider: + oauth.init_oauth() task = asyncio.create_task(run_collector()) log.info("news_mcp no ar; coletor rodando a cada %d min", config.POLL_INTERVAL_MIN) try:@@ -33,7 +41,19 @@ finally:
task.cancel() -mcp = FastMCP("news_mcp", lifespan=lifespan) +if config.ISSUER_URL: + _auth_settings = AuthSettings( + issuer_url=config.ISSUER_URL, + resource_server_url=config.ISSUER_URL + "/mcp", + required_scopes=["news"], + client_registration_options=ClientRegistrationOptions( + enabled=True, valid_scopes=["news"], default_scopes=["news"] + ), + revocation_options=RevocationOptions(enabled=True), + ) + mcp = FastMCP("news_mcp", lifespan=lifespan, auth_server_provider=_provider, auth=_auth_settings) +else: + mcp = FastMCP("news_mcp", lifespan=lifespan) @mcp.tool(@@ -138,11 +158,39 @@ return JSONResponse({"error": "unauthorized"}, status_code=401)
return await call_next(request) +class AuthorizeGateMiddleware(BaseHTTPMiddleware): + def __init__(self, app, password: str): + super().__init__(app) + self._password = password + + async def dispatch(self, request: Request, call_next): + if request.url.path.rstrip("/").endswith("/authorize"): + if not self._check(request.headers.get("authorization", "")): + return Response(status_code=401, + headers={"WWW-Authenticate": 'Basic realm="news-mcp"'}) + return await call_next(request) + + def _check(self, header: str) -> bool: + if not self._password or not header.startswith("Basic "): + return False + try: + _, _, pwd = base64.b64decode(header[6:]).decode().partition(":") + except Exception: + return False + return secrets.compare_digest(pwd, self._password) + + def main() -> None: - if not config.TOKEN: - log.warning("NEWS_MCP_TOKEN vazio: servidor SEM autenticação (ok só em dev local).") app = mcp.streamable_http_app() - app.add_middleware(BearerAuthMiddleware, token=config.TOKEN) + if config.ISSUER_URL: + if not config.AUTH_PASSWORD: + log.warning("NEWS_MCP_AUTH_PASSWORD vazio: /authorize ficará bloqueado até você definir uma senha.") + app.add_middleware(AuthorizeGateMiddleware, password=config.AUTH_PASSWORD) + log.info("OAuth habilitado (issuer %s)", config.ISSUER_URL) + else: + if not config.TOKEN: + log.warning("NEWS_MCP_TOKEN vazio: servidor SEM autenticação (ok só em dev local).") + app.add_middleware(BearerAuthMiddleware, token=config.TOKEN) uvicorn.run(app, host=config.HOST, port=config.PORT, log_level="info")