feat(mcp): flux OAuth authorize+token avec gate abonnement (#114)

Remplace le stub 501 de src/mcp/oauth/authorize.py par le flux réel
authlib (GET consentement, POST émission de code, échange de token),
avec gate d'abonnement avant tout affichage du consentement.

Deux ajustements de compatibilité authlib 1.7.2 dans server.py,
découverts en exécutant le flux bout-en-bout pour la première fois :
AUTHLIB_INSECURE_TRANSPORT en mode DEVELOPMENT (le client de test
Flask ne sert pas en HTTPS) et OAUTH2_REFRESH_TOKEN_GENERATOR (off
par défaut côté Flask, requis pour émettre les refresh_token du scope
offline_access). Détails dans .superpowers/sdd/task-9-report.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Colin Maudry
2026-07-13 14:27:58 +02:00
parent 633462f18b
commit 4e3c8517ad
3 changed files with 299 additions and 2 deletions
+38 -2
View File
@@ -1,6 +1,42 @@
from urllib.parse import urlencode
from flask import redirect, request
from flask_login import current_user
from src.mcp.oauth import consent
def _login_redirect():
target = f"/oauth/authorize?{urlencode(request.args)}"
return redirect(f"/connexion?next={target}")
def authorize(): def authorize():
return "", 501 from src.mcp.oauth.routes import _server
if not current_user.is_authenticated:
return _login_redirect()
if not consent.subscription_ok(int(current_user.id)):
return consent.render_subscription_required(), 403
if request.method == "GET":
grant = _server.get_consent_grant(end_user=current_user)
client = grant.client
scope = grant.request.scope or "mcp"
return consent.render_consent(
client.client_metadata.get("client_name", client.get_client_id()),
grant.request.redirect_uri or client.get_default_redirect_uri(),
scope,
)
# POST
if request.form.get("confirm") != "yes":
return _server.create_authorization_response(grant_user=None)
return _server.create_authorization_response(grant_user=int(current_user.id))
def token(): def token():
return "", 501 from src.mcp.oauth.routes import _server
return _server.create_token_response()
+12
View File
@@ -9,10 +9,18 @@ from authlib.oauth2.rfc7591 import ClientRegistrationEndpoint
from authlib.oauth2.rfc7636 import CodeChallenge from authlib.oauth2.rfc7636 import CodeChallenge
from src.mcp.oauth import metadata, store from src.mcp.oauth import metadata, store
from src.utils import DEVELOPMENT
ACCESS_TTL = 3600 # 1 h ACCESS_TTL = 3600 # 1 h
REFRESH_TTL = 5184000 # 60 j REFRESH_TTL = 5184000 # 60 j
if DEVELOPMENT:
# authlib refuse tout URI non-https (InsecureTransportError), y compris
# http://colibre.fr utilisé par le client de test Flask (pas de TLS local).
# Comportement identique à SESSION_COOKIE_SECURE = not DEVELOPMENT dans
# src/auth/setup.py : ne s'applique jamais en production (DEVELOPMENT=false).
os.environ.setdefault("AUTHLIB_INSECURE_TRANSPORT", "1")
def _db() -> str: def _db() -> str:
return os.environ["USERS_DB_PATH"] return os.environ["USERS_DB_PATH"]
@@ -224,6 +232,10 @@ class _RevocationEndpoint(RevocationEndpoint):
def create_authorization_server(app) -> AuthorizationServer: def create_authorization_server(app) -> AuthorizationServer:
# authlib n'émet un refresh_token que si ce flag est activé (défaut: False) ;
# requis puisque RefreshTokenGrant est enregistré ci-dessous et que le scope
# "offline_access" (DCR) suppose l'émission d'un refresh_token.
app.config.setdefault("OAUTH2_REFRESH_TOKEN_GENERATOR", True)
server = AuthorizationServer(app, query_client=query_client, save_token=save_token) server = AuthorizationServer(app, query_client=query_client, save_token=save_token)
server.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)]) server.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)])
server.register_grant(RefreshTokenGrant) server.register_grant(RefreshTokenGrant)
+249
View File
@@ -0,0 +1,249 @@
import base64
import hashlib
import re
import pytest
from flask import Flask
from flask_login import LoginManager, UserMixin, login_user
class _U(UserMixin):
def __init__(self, uid):
self.id = uid
def _pkce():
verifier = "a" * 64
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
return verifier, challenge
@pytest.fixture
def flow_app(monkeypatch, tmp_path):
from src.auth import db as auth_db
from src.mcp.oauth import routes, store
from src.subscriptions import db as sub_db
db_path = tmp_path / "u.sqlite"
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr")
auth_db.reset_conn_for_tests()
auth_db.init_schema()
sub_db.init_schema()
store.init_schema(db_path)
app = Flask(__name__)
app.config["SECRET_KEY"] = "x"
app.config["SERVER_NAME"] = "colibre.fr"
lm = LoginManager()
lm.init_app(app)
lm.user_loader(lambda uid: _U(int(uid)))
routes.init_oauth(app)
@app.route("/_test_login/<int:uid>")
def _test_login(uid):
login_user(_U(uid))
return "ok"
yield app, db_path
auth_db.reset_conn_for_tests()
def _register(client):
resp = client.post(
"/oauth/register",
json={
"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
"client_name": "Claude",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"scope": "mcp offline_access",
},
)
return resp.get_json()["client_id"]
def test_authorize_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("nosub@ex.fr", "h")
client = app.test_client()
client.get(f"/_test_login/{uid}")
cid = _register(client)
_, challenge = _pkce()
resp = client.get(
"/oauth/authorize",
query_string={
"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",
},
)
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
from src.subscriptions import db as sub_db
app, db_path = flow_app
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
uid = auth_db.create_user("sub@ex.fr", "h")
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
sub_db.set_status(sub_id, "active")
client = app.test_client()
client.get(f"/_test_login/{uid}")
cid = _register(client)
verifier, 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",
}
# GET affiche le consentement
assert b"Autoriser" in client.get("/oauth/authorize", query_string=qs).data
# POST confirme → redirection avec ?code=
resp = client.post("/oauth/authorize", query_string=qs, data={"confirm": "yes"})
assert resp.status_code == 302
code = re.search(r"code=([^&]+)", resp.headers["Location"]).group(1)
# échange du code
tok = client.post(
"/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"client_id": cid,
"code_verifier": verifier,
"resource": "https://colibre.fr/_mcp",
},
)
assert tok.status_code == 200
access = tok.get_json()["access_token"]
row = store.get_token_by_access(db_path, access)
assert row["user_id"] == uid
assert row["resource"] == "https://colibre.fr/_mcp"
def test_wrong_verifier_rejected(flow_app, monkeypatch):
from src.auth import db as auth_db
from src.subscriptions import db as sub_db
app, _ = flow_app
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
uid = auth_db.create_user("sub2@ex.fr", "h")
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
sub_db.set_status(sub_id, "active")
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",
}
resp = client.post("/oauth/authorize", query_string=qs, data={"confirm": "yes"})
code = re.search(r"code=([^&]+)", resp.headers["Location"]).group(1)
tok = client.post(
"/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"client_id": cid,
"code_verifier": "wrong" * 13,
},
)
assert tok.status_code == 400
assert tok.get_json()["error"] == "invalid_grant"
def _obtain_tokens(client, cid, monkeypatch):
verifier, challenge = _pkce()
qs = {
"client_id": cid,
"response_type": "code",
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"scope": "mcp offline_access",
"code_challenge": challenge,
"code_challenge_method": "S256",
}
resp = client.post("/oauth/authorize", query_string=qs, data={"confirm": "yes"})
code = re.search(r"code=([^&]+)", resp.headers["Location"]).group(1)
tok = client.post(
"/oauth/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "https://claude.ai/api/mcp/auth_callback",
"client_id": cid,
"code_verifier": verifier,
},
)
return tok.get_json()
def test_refresh_rotates_and_requires_subscription(flow_app, monkeypatch):
from src.auth import db as auth_db
from src.mcp.oauth import store
from src.subscriptions import db as sub_db
app, db_path = flow_app
monkeypatch.setattr("src.mcp.oauth.consent.TOUS_ABONNES", False)
uid = auth_db.create_user("ref@ex.fr", "h")
_, sub_id = sub_db.create_pending(uid, "colibre-1", "simple")
sub_db.set_status(sub_id, "active")
client = app.test_client()
client.get(f"/_test_login/{uid}")
cid = _register(client)
first = _obtain_tokens(client, cid, monkeypatch)
assert "refresh_token" in first
# Refresh réussi → nouveau refresh (rotation), ancien révoqué.
r = client.post(
"/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": first["refresh_token"],
"client_id": cid,
},
)
assert r.status_code == 200
assert r.get_json()["refresh_token"] != first["refresh_token"]
assert store.get_token_by_refresh(db_path, first["refresh_token"])["revoked_at"]
# Abonnement perdu → refresh refusé (invalid_grant).
new_refresh = r.get_json()["refresh_token"]
monkeypatch.setattr(
"src.mcp.oauth.consent.has_active_subscription", lambda u: False
)
r2 = client.post(
"/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": new_refresh,
"client_id": cid,
},
)
assert r2.status_code == 400
assert r2.get_json()["error"] == "invalid_grant"