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)}.
"
'"
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