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
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user