diff --git a/src/app.py b/src/app.py index c584b2d..55a8923 100644 --- a/src/app.py +++ b/src/app.py @@ -137,6 +137,29 @@ if _mcp_enabled: init_mcp_auth(app.server) + # Serveur d'autorisation OAuth 2.1 (scope B2, #114) : découverte, DCR, + # authorize/token, révocation. Réutilise la session flask_login. + from src.mcp import usage # noqa: E402 + from src.mcp.oauth import routes as oauth_routes # noqa: E402 + from src.mcp.oauth import store as oauth_store # noqa: E402 + + _users_db = os.environ["USERS_DB_PATH"] + oauth_store.init_schema(_users_db) + usage.init_schema(_users_db) + usage.purge_older_than(_users_db) + + oauth_routes.init_oauth(app.server) + + # Exempter les routes OAuth du CSRF (POST externes JSON-RPC/form sans jeton). + if _auth_csrf is not None: + for _rule in app.server.url_map.iter_rules(): + if _rule.rule.startswith("/oauth") or _rule.rule.startswith( + "/.well-known/oauth" + ): + _vf = app.server.view_functions.get(_rule.endpoint) + if _vf is not None: + _auth_csrf.exempt(_vf) + from src.subscriptions.setup import init_subscriptions # noqa: E402 init_subscriptions(app.server) diff --git a/src/migrations.py b/src/migrations.py index 71c08e8..fcbc40d 100644 --- a/src/migrations.py +++ b/src/migrations.py @@ -46,6 +46,34 @@ _MIGRATIONS: list[tuple[str, str]] = [ "0007_add_kind_to_api_tokens", "ALTER TABLE api_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'api'", ), + ( + "0008_create_oauth_clients", + "CREATE TABLE IF NOT EXISTS oauth_clients (" + "client_id TEXT PRIMARY KEY, client_metadata TEXT NOT NULL, " + "created_at TEXT NOT NULL)", + ), + ( + "0009_create_oauth_codes", + "CREATE TABLE IF NOT EXISTS oauth_codes (" + "code_hash TEXT PRIMARY KEY, client_id TEXT NOT NULL, user_id INTEGER NOT NULL, " + "redirect_uri TEXT, code_challenge TEXT, code_challenge_method TEXT, " + "scope TEXT, resource TEXT, expires_at TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0)", + ), + ( + "0010_create_oauth_tokens", + "CREATE TABLE IF NOT EXISTS oauth_tokens (" + "id INTEGER PRIMARY KEY, access_token_hash TEXT NOT NULL UNIQUE, " + "refresh_token_hash TEXT UNIQUE, client_id TEXT NOT NULL, user_id INTEGER NOT NULL, " + "scope TEXT, resource TEXT NOT NULL, issued_at TEXT NOT NULL, " + "access_expires_at TEXT NOT NULL, refresh_expires_at TEXT, revoked_at TEXT, " + "count_total INTEGER NOT NULL DEFAULT 0, last_used_at TEXT)", + ), + ( + "0011_create_mcp_usage", + "CREATE TABLE IF NOT EXISTS mcp_usage (" + "id INTEGER PRIMARY KEY, user_id INTEGER, token_id INTEGER, " + "kind TEXT NOT NULL, created_at TEXT NOT NULL)", + ), ] diff --git a/tests/mcp/test_app_wiring.py b/tests/mcp/test_app_wiring.py index d1dd039..0f2a354 100644 --- a/tests/mcp/test_app_wiring.py +++ b/tests/mcp/test_app_wiring.py @@ -49,3 +49,41 @@ def test_mcp_endpoint_guarded_and_csrf_exempt(monkeypatch, tmp_path): else: del sys.modules[name] auth_db.reset_conn_for_tests() + + +def test_oauth_wellknown_served_when_mcp_enabled(monkeypatch, tmp_path): + # DB éphémère + secrets requis par init_auth/init_subscriptions. + monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "users.test.sqlite")) + monkeypatch.setenv("SECRET_KEY", "test-secret-key") + monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr") + monkeypatch.setenv("DASH_MCP_ENABLED", "true") + + from src.auth import db as auth_db + + auth_db.reset_conn_for_tests() + + # Voir commentaire ci-dessus : reload de src.app pollue sys.modules pour + # src.pages.* ; on snapshot/restaure de la même façon. + snapshot = { + name: mod + for name, mod in sys.modules.items() + if name == "src.app" or name.startswith("src.pages") + } + try: + import src.app as app_module + + app_module = importlib.reload(app_module) + client = app_module.app.server.test_client() + + resp = client.get("/.well-known/oauth-protected-resource") + assert resp.status_code == 200 + assert resp.get_json()["resource"] == "https://colibre.fr/_mcp" + finally: + for name in [ + n for n in sys.modules if n == "src.app" or n.startswith("src.pages") + ]: + if name in snapshot: + sys.modules[name] = snapshot[name] + else: + del sys.modules[name] + auth_db.reset_conn_for_tests()