diff --git a/src/mcp/oauth/authorize.py b/src/mcp/oauth/authorize.py index 3f7714e..4a60285 100644 --- a/src/mcp/oauth/authorize.py +++ b/src/mcp/oauth/authorize.py @@ -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() diff --git a/src/mcp/oauth/server.py b/src/mcp/oauth/server.py index 48dec94..a017601 100644 --- a/src/mcp/oauth/server.py +++ b/src/mcp/oauth/server.py @@ -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) diff --git a/tests/mcp/test_oauth_flow.py b/tests/mcp/test_oauth_flow.py new file mode 100644 index 0000000..dbcc671 --- /dev/null +++ b/tests/mcp/test_oauth_flow.py @@ -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/") + 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"