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():
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():
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 src.mcp.oauth import metadata, store
from src.utils import DEVELOPMENT
ACCESS_TTL = 3600 # 1 h
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:
return os.environ["USERS_DB_PATH"]
@@ -224,6 +232,10 @@ class _RevocationEndpoint(RevocationEndpoint):
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.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)])
server.register_grant(RefreshTokenGrant)