4e3c8517ad
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>
250 lines
7.8 KiB
Python
250 lines
7.8 KiB
Python
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"
|