From d2fa17761dab0d956e96e39934a43127b2ae0815 Mon Sep 17 00:00:00 2001 From: Colin Maudry Date: Mon, 13 Jul 2026 15:07:59 +0200 Subject: [PATCH] feat(mcp): durcir le consentement OAuth (remember-cookie SameSite + CSRF) (#114) Co-Authored-By: Claude Opus 4.8 --- src/app.py | 10 +++++++--- src/auth/setup.py | 3 +++ src/mcp/oauth/authorize.py | 2 ++ src/mcp/oauth/consent.py | 5 ++++- tests/mcp/test_oauth_consent.py | 21 +++++++++++++++++++++ tests/mcp/test_oauth_flow.py | 24 ++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/app.py b/src/app.py index 55a8923..f837c24 100644 --- a/src/app.py +++ b/src/app.py @@ -150,11 +150,15 @@ if _mcp_enabled: oauth_routes.init_oauth(app.server) - # Exempter les routes OAuth du CSRF (POST externes JSON-RPC/form sans jeton). + # Exempter du CSRF les endpoints OAuth machine-à-machine (POST externes sans + # cookie) et les documents de découverte (GET). /oauth/authorize N'EST PAS + # exempté : endpoint de consentement authentifié par cookie de session, donc + # protégé par CSRF via un jeton dans le formulaire de consentement. if _auth_csrf is not None: + _csrf_exempt_oauth = ("/oauth/token", "/oauth/register", "/oauth/revoke") for _rule in app.server.url_map.iter_rules(): - if _rule.rule.startswith("/oauth") or _rule.rule.startswith( - "/.well-known/oauth" + if _rule.rule in _csrf_exempt_oauth or _rule.rule.startswith( + "/.well-known/" ): _vf = app.server.view_functions.get(_rule.endpoint) if _vf is not None: diff --git a/src/auth/setup.py b/src/auth/setup.py index ea6c717..47ce315 100644 --- a/src/auth/setup.py +++ b/src/auth/setup.py @@ -32,6 +32,9 @@ def init_auth(app: Flask) -> None: app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["SESSION_COOKIE_SAMESITE"] = "Lax" app.config["SESSION_COOKIE_SECURE"] = not DEVELOPMENT + app.config["REMEMBER_COOKIE_SAMESITE"] = "Lax" + app.config["REMEMBER_COOKIE_SECURE"] = not DEVELOPMENT + app.config["REMEMBER_COOKIE_HTTPONLY"] = True app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 30 # 30 jours db.init_schema() diff --git a/src/mcp/oauth/authorize.py b/src/mcp/oauth/authorize.py index 4a60285..7abc244 100644 --- a/src/mcp/oauth/authorize.py +++ b/src/mcp/oauth/authorize.py @@ -2,6 +2,7 @@ from urllib.parse import urlencode from flask import redirect, request from flask_login import current_user +from flask_wtf.csrf import generate_csrf from src.mcp.oauth import consent @@ -28,6 +29,7 @@ def authorize(): client.client_metadata.get("client_name", client.get_client_id()), grant.request.redirect_uri or client.get_default_redirect_uri(), scope, + csrf_token=generate_csrf(), ) # POST diff --git a/src/mcp/oauth/consent.py b/src/mcp/oauth/consent.py index ca59f4a..5a675c0 100644 --- a/src/mcp/oauth/consent.py +++ b/src/mcp/oauth/consent.py @@ -21,7 +21,9 @@ def render_subscription_required() -> str: ) -def render_consent(client_name: str, redirect_uri: str, scope: str) -> str: +def render_consent( + client_name: str, redirect_uri: str, scope: str, csrf_token: str = "" +) -> str: host = urlparse(redirect_uri).netloc or redirect_uri return ( "" @@ -31,6 +33,7 @@ def render_consent(client_name: str, redirect_uri: str, scope: str) -> str: "colibre en votre nom via le connecteur MCP.

" f"

Vous serez redirigé vers {escape(host)}.

" '
' + f'' ' ' '' "
" diff --git a/tests/mcp/test_oauth_consent.py b/tests/mcp/test_oauth_consent.py index da16f88..856b965 100644 --- a/tests/mcp/test_oauth_consent.py +++ b/tests/mcp/test_oauth_consent.py @@ -27,3 +27,24 @@ def test_render_consent_shows_redirect_host(): def test_render_subscription_required_links_abonnement(): html = consent.render_subscription_required() assert "/compte/abonnement" in html + + +def test_render_consent_escapes_client_name(): + html = consent.render_consent( + "", + "https://claude.ai/api/mcp/auth_callback", + "mcp", + ) + assert "" not in html + assert "<script>" in html + + +def test_render_consent_includes_csrf_token(): + html = consent.render_consent( + "Claude", + "https://claude.ai/api/mcp/auth_callback", + "mcp", + csrf_token="tok-abc-123", + ) + assert 'name="csrf_token"' in html + assert "tok-abc-123" in html diff --git a/tests/mcp/test_oauth_flow.py b/tests/mcp/test_oauth_flow.py index dbcc671..2158379 100644 --- a/tests/mcp/test_oauth_flow.py +++ b/tests/mcp/test_oauth_flow.py @@ -92,6 +92,30 @@ def test_authorize_requires_subscription(flow_app, monkeypatch): assert b"Abonnement requis" in resp.data +def test_authorize_post_requires_subscription(flow_app, monkeypatch): + from src.auth import db as auth_db + + app, _ = flow_app + monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False) + uid = auth_db.create_user("nosub2@ex.fr", "h") + client = app.test_client() + client.get(f"/_test_login/{uid}") + cid = _register(client) + _, challenge = _pkce() + qs = { + "client_id": cid, + "response_type": "code", + "redirect_uri": "https://claude.ai/api/mcp/auth_callback", + "scope": "mcp", + "code_challenge": challenge, + "code_challenge_method": "S256", + "resource": "https://colibre.fr/_mcp", + } + resp = client.post("/oauth/authorize", query_string=qs, data={"confirm": "yes"}) + assert resp.status_code == 403 + assert b"Abonnement requis" in resp.data + + def test_full_flow_issues_audience_bound_token(flow_app, monkeypatch): from src.auth import db as auth_db from src.mcp.oauth import store