feat(mcp): garde /_mcp unifié statique+OAuth, audience & usage (#114)
Le garde before_request de /_mcp accepte désormais les jetons OAuth opaques (store.get_token_by_access) en plus des jetons statiques colibre_*, avec vérification d'audience (resource == mcp_resource) et d'expiration. Le 401 porte resource_metadata pour le découverte RFC 9728. Sur succès, incrémente le compteur du bon store et journalise dans mcp_usage (best-effort). Corrige aussi une régression d'assertion stricte sur le header WWW-Authenticate dans test_app_wiring.py, cassée par le nouveau suffixe resource_metadata.
This commit is contained in:
+48
-6
@@ -1,18 +1,31 @@
|
|||||||
import os
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from flask import Flask, jsonify, request
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
from src.api import tokens_db
|
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.subscriptions.db import has_active_subscription
|
||||||
from src.utils import TOUS_ABONNES
|
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():
|
def _unauthorized():
|
||||||
resp = jsonify(
|
resp = jsonify(
|
||||||
{"error": "unauthorized", "message": "Jeton MCP absent ou invalide."}
|
{"error": "unauthorized", "message": "Jeton MCP absent ou invalide."}
|
||||||
)
|
)
|
||||||
resp.status_code = 401
|
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
|
return resp
|
||||||
|
|
||||||
|
|
||||||
@@ -27,6 +40,28 @@ def _forbidden():
|
|||||||
return resp
|
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():
|
def _authenticate_mcp():
|
||||||
header = request.headers.get("Authorization", "")
|
header = request.headers.get("Authorization", "")
|
||||||
if not header.startswith("Bearer "):
|
if not header.startswith("Bearer "):
|
||||||
@@ -36,17 +71,24 @@ def _authenticate_mcp():
|
|||||||
return _unauthorized()
|
return _unauthorized()
|
||||||
|
|
||||||
db_path = os.environ["USERS_DB_PATH"]
|
db_path = os.environ["USERS_DB_PATH"]
|
||||||
row = tokens_db.get_token_by_plaintext(db_path, token)
|
if token.startswith("colibre_"):
|
||||||
if row is None or row["revoked_at"] is not None or row["kind"] != "mcp":
|
user_id, meta = _authorize_static(db_path, token)
|
||||||
return _unauthorized()
|
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:
|
if user_id is None:
|
||||||
return _forbidden()
|
return _forbidden()
|
||||||
if not (TOUS_ABONNES or has_active_subscription(user_id)):
|
if not (TOUS_ABONNES or has_active_subscription(user_id)):
|
||||||
return _forbidden()
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
# ce qui prouve exemption CSRF + garde câblés sur /_mcp.
|
||||||
resp = client.post("/_mcp", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
|
resp = client.post("/_mcp", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
|
||||||
assert resp.status_code == 401
|
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:
|
finally:
|
||||||
# Restaurer les objets-modules d'origine et purger ceux créés par le
|
# Restaurer les objets-modules d'origine et purger ceux créés par le
|
||||||
# reload, pour ne pas polluer les tests suivants.
|
# reload, pour ne pas polluer les tests suivants.
|
||||||
|
|||||||
+91
-1
@@ -11,10 +11,15 @@ def mcp_app(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
db_path = tmp_path / "users.test.sqlite"
|
db_path = tmp_path / "users.test.sqlite"
|
||||||
monkeypatch.setenv("USERS_DB_PATH", str(db_path))
|
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.reset_conn_for_tests()
|
||||||
auth_db.init_schema()
|
auth_db.init_schema()
|
||||||
sub_db.init_schema()
|
sub_db.init_schema()
|
||||||
tokens_db.init_schema(db_path)
|
tokens_db.init_schema(db_path)
|
||||||
|
oauth_store.init_schema(db_path)
|
||||||
|
usage.init_schema(db_path)
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@@ -41,7 +46,7 @@ def test_missing_header_401_with_challenge(mcp_app):
|
|||||||
app, _ = mcp_app
|
app, _ = mcp_app
|
||||||
resp = app.test_client().post("/_mcp")
|
resp = app.test_client().post("/_mcp")
|
||||||
assert resp.status_code == 401
|
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):
|
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")
|
token, _ = tokens_db.create_token(db_path, "x", user_id=uid, kind="mcp")
|
||||||
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
|
resp = app.test_client().post("/_mcp", headers={"Authorization": f"Bearer {token}"})
|
||||||
assert resp.status_code == 200
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user