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:
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user