diff --git a/src/mcp/auth.py b/src/mcp/auth.py index c9f8a0b..b0a9f20 100644 --- a/src/mcp/auth.py +++ b/src/mcp/auth.py @@ -1,18 +1,31 @@ import os +from datetime import datetime, timezone from flask import Flask, jsonify, request from src.api import tokens_db +from src.mcp import usage +from src.mcp.oauth import metadata, store from src.subscriptions.db import has_active_subscription from src.utils import TOUS_ABONNES +def _base() -> str: + return os.getenv("APP_BASE_URL", "").rstrip("/") + + +def _resource_metadata_url() -> str: + return f"{_base()}/.well-known/oauth-protected-resource/_mcp" + + def _unauthorized(): resp = jsonify( {"error": "unauthorized", "message": "Jeton MCP absent ou invalide."} ) resp.status_code = 401 - resp.headers["WWW-Authenticate"] = 'Bearer realm="colibre-mcp"' + resp.headers["WWW-Authenticate"] = ( + f'Bearer realm="colibre-mcp", resource_metadata="{_resource_metadata_url()}"' + ) return resp @@ -27,6 +40,28 @@ def _forbidden(): return resp +def _expired(iso_ts: str) -> bool: + return datetime.fromisoformat(iso_ts) < datetime.now(timezone.utc) + + +def _authorize_static(db_path, token): + row = tokens_db.get_token_by_plaintext(db_path, token) + if row is None or row["revoked_at"] is not None or row["kind"] != "mcp": + return None, None + return row["user_id"], ("static", row["id"]) + + +def _authorize_oauth(db_path, token): + row = store.get_token_by_access(db_path, token) + if row is None or row["revoked_at"] is not None: + return None, None + if _expired(row["access_expires_at"]): + return None, None + if row["resource"] != metadata.mcp_resource(_base()): + return None, None + return row["user_id"], ("oauth", row["id"]) + + def _authenticate_mcp(): header = request.headers.get("Authorization", "") if not header.startswith("Bearer "): @@ -36,17 +71,24 @@ def _authenticate_mcp(): return _unauthorized() db_path = os.environ["USERS_DB_PATH"] - row = tokens_db.get_token_by_plaintext(db_path, token) - if row is None or row["revoked_at"] is not None or row["kind"] != "mcp": - return _unauthorized() + if token.startswith("colibre_"): + user_id, meta = _authorize_static(db_path, token) + else: + user_id, meta = _authorize_oauth(db_path, token) - user_id = row["user_id"] + if meta is None: + return _unauthorized() if user_id is None: return _forbidden() if not (TOUS_ABONNES or has_active_subscription(user_id)): return _forbidden() - tokens_db.increment_usage(db_path, row["id"]) + kind, token_id = meta + if kind == "static": + tokens_db.increment_usage(db_path, token_id) + else: + store.increment_usage(db_path, token_id) + usage.record(db_path, user_id, token_id, kind) return None diff --git a/tests/mcp/test_app_wiring.py b/tests/mcp/test_app_wiring.py index f28b25c..d1dd039 100644 --- a/tests/mcp/test_app_wiring.py +++ b/tests/mcp/test_app_wiring.py @@ -35,7 +35,9 @@ def test_mcp_endpoint_guarded_and_csrf_exempt(monkeypatch, tmp_path): # ce qui prouve exemption CSRF + garde câblés sur /_mcp. resp = client.post("/_mcp", json={"jsonrpc": "2.0", "method": "ping", "id": 1}) assert resp.status_code == 401 - assert resp.headers.get("WWW-Authenticate") == 'Bearer realm="colibre-mcp"' + assert resp.headers.get("WWW-Authenticate", "").startswith( + 'Bearer realm="colibre-mcp"' + ) finally: # Restaurer les objets-modules d'origine et purger ceux créés par le # reload, pour ne pas polluer les tests suivants. diff --git a/tests/mcp/test_auth.py b/tests/mcp/test_auth.py index 9e3b406..2a5b6dd 100644 --- a/tests/mcp/test_auth.py +++ b/tests/mcp/test_auth.py @@ -11,10 +11,15 @@ def mcp_app(monkeypatch, tmp_path): db_path = tmp_path / "users.test.sqlite" monkeypatch.setenv("USERS_DB_PATH", str(db_path)) + from src.mcp import usage + from src.mcp.oauth import store as oauth_store + auth_db.reset_conn_for_tests() auth_db.init_schema() sub_db.init_schema() tokens_db.init_schema(db_path) + oauth_store.init_schema(db_path) + usage.init_schema(db_path) app = Flask(__name__) @@ -41,7 +46,7 @@ def test_missing_header_401_with_challenge(mcp_app): app, _ = mcp_app resp = app.test_client().post("/_mcp") assert resp.status_code == 401 - assert resp.headers["WWW-Authenticate"] == 'Bearer realm="colibre-mcp"' + assert resp.headers["WWW-Authenticate"].startswith('Bearer realm="colibre-mcp"') def test_empty_bearer_401(mcp_app): @@ -120,3 +125,88 @@ def test_tous_abonnes_bypasses_subscription(mcp_app, monkeypatch): token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp") resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"}) assert resp.status_code == 200 + + +def test_oauth_token_active_subscription_passes(mcp_app, monkeypatch): + import time + + from src.mcp.oauth import server, store + + monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr") + app, db_path = mcp_app + store.init_schema(db_path) + uid = _subscribed_uid() + store.create_client(db_path, "cid", {"redirect_uris": []}) + store.save_token( + db_path, + access_token="opaque123", + refresh_token=None, + client_id="cid", + user_id=uid, + scope="mcp", + resource="https://colibre.fr/_mcp", + access_expires_at=server._iso(time.time() + 3600), + refresh_expires_at=None, + ) + resp = app.test_client().post( + "/_mcp", headers={"Authorization": "Bearer opaque123"} + ) + assert resp.status_code == 200 + + +def test_oauth_token_wrong_audience_401(mcp_app, monkeypatch): + import time + + from src.mcp.oauth import server, store + + monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr") + app, db_path = mcp_app + store.init_schema(db_path) + uid = _subscribed_uid() + store.save_token( + db_path, + access_token="badaud", + refresh_token=None, + client_id="cid", + user_id=uid, + scope="mcp", + resource="https://evil.example/_mcp", + access_expires_at=server._iso(time.time() + 3600), + refresh_expires_at=None, + ) + resp = app.test_client().post("/_mcp", headers={"Authorization": "Bearer badaud"}) + assert resp.status_code == 401 + + +def test_401_carries_resource_metadata(mcp_app, monkeypatch): + monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr") + app, _ = mcp_app + resp = app.test_client().post("/_mcp") + assert resp.status_code == 401 + assert "resource_metadata=" in resp.headers["WWW-Authenticate"] + + +def test_oauth_token_no_subscription_403(mcp_app, monkeypatch): + import time + + from src.auth import db as auth_db + from src.mcp.oauth import server, store + + monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr") + monkeypatch.setattr("src.mcp.auth.TOUS_ABONNES", False) + app, db_path = mcp_app + store.init_schema(db_path) + uid = auth_db.create_user("nosuboauth@ex.fr", "h") # aucun abonnement + store.save_token( + db_path, + access_token="nosubtok", + refresh_token=None, + client_id="cid", + user_id=uid, + scope="mcp", + resource="https://colibre.fr/_mcp", + access_expires_at=server._iso(time.time() + 3600), + refresh_expires_at=None, + ) + resp = app.test_client().post("/_mcp", headers={"Authorization": "Bearer nosubtok"}) + assert resp.status_code == 403