# MCP OAuth 2.1 (scope B2) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Faire de colibre son propre serveur d'autorisation OAuth 2.1 conforme MCP pour que Claude.ai / Claude Desktop / ChatGPT se connectent au connecteur MCP, en conservant le chemin jeton statique (clients CLI). **Architecture:** authlib (déjà en dépendance) fournit les grants OAuth 2.1 (authorization_code+PKCE, refresh, DCR) ; le stockage est en SQLite brut (pattern `src/api/tokens_db.py`). colibre est à la fois serveur d'autorisation et resource server sur le même Flask ; l'étape de consentement réutilise la session flask_login. Les tokens d'accès sont opaques, liés en audience (`resource`, RFC 8707), validés par lookup haché dans le garde `/_mcp` unifié. **Tech Stack:** Python, Flask, authlib 1.7.2, sqlite3, flask_login, pytest. ## Global Constraints - Imports internes toujours préfixés `src.` (ex. `from src.mcp.oauth import store`). - Stockage : `sqlite3` brut, pas de SQLAlchemy. Suivre le pattern `src/api/tokens_db.py` (`_connect(db_path)` contextmanager, `init_schema(db_path)`, hachage SHA-256). - Chemin de la base : variable d'env `USERS_DB_PATH` (mêmes fichier/DB que `api_tokens`, `subscriptions`, `users`). - Base URL / issuer : `APP_BASE_URL` (sans slash final). HTTPS obligatoire hors dev ; localhost toléré en dev. - Audience canonique du serveur MCP : `f"{APP_BASE_URL}/_mcp"`. - Durées de vie : access token **3600 s (1 h)**, refresh token **5184000 s (60 j)**. Rotation du refresh à chaque usage. - Scopes annoncés : `["mcp", "offline_access"]`. - Activation : tout le câblage OAuth vit dans le bloc `if _mcp_enabled:` de `src/app.py` (piloté par `DASH_MCP_ENABLED=true`). - `pre-commit` doit être exécuté avant chaque `git add` (ruff formate). Lancer les tests avec `uv run pytest`. - Tests d'un module ciblés sur leur propre fichier ; ne pas lancer toute la suite avant la dernière tâche. --- ## File Structure - Create `src/mcp/oauth/__init__.py` — package vide. - Create `src/mcp/oauth/store.py` — CRUD sqlite : clients (DCR), codes d'autorisation, tokens. - Create `src/mcp/oauth/metadata.py` — documents JSON RFC 9728 (PRM) + RFC 8414 (AS). - Create `src/mcp/oauth/consent.py` — gate abonnement + rendu HTML des écrans consentement / abonnement requis. - Create `src/mcp/oauth/server.py` — configuration authlib (grants, `query_client`, `save_token`, DCR). - Create `src/mcp/oauth/routes.py` — blueprint Flask (well-known, /oauth/register, /authorize, /token, /revoke) + `init_oauth(app)`. - Create `src/mcp/usage.py` — journal `mcp_usage` (record, count_since, purge). - Modify `src/migrations.py` — 4 migrations (oauth_clients, oauth_codes, oauth_tokens, mcp_usage). - Modify `src/mcp/auth.py` — garde `/_mcp` unifié (statique + OAuth), header `resource_metadata`, journal usage. - Modify `src/pages/compte/mcp.py` — instructions Claude.ai / ChatGPT. - Modify `src/app.py` — `init_oauth`, exemption CSRF, init schémas, purge au démarrage. - Test files under `tests/mcp/`. --- ### Task 1: OAuth store — schéma & clients (DCR) **Files:** - Create: `src/mcp/oauth/__init__.py` - Create: `src/mcp/oauth/store.py` - Test: `tests/mcp/test_oauth_store.py` **Interfaces:** - Produces: - `init_schema(db_path) -> None` - `create_client(db_path, client_id: str, metadata: dict) -> None` - `get_client(db_path, client_id: str) -> dict | None` — renvoie `{"client_id", "client_metadata": dict, "created_at"}` - [ ] **Step 1: Create empty package file** Create `src/mcp/oauth/__init__.py` (empty). - [ ] **Step 2: Write the failing test** Create `tests/mcp/test_oauth_store.py`: ```python from src.mcp.oauth import store def test_create_and_get_client(tmp_path): db = tmp_path / "u.sqlite" store.init_schema(db) meta = {"redirect_uris": ["https://claude.ai/api/mcp/auth_callback"], "scope": "mcp"} store.create_client(db, "abc123", meta) row = store.get_client(db, "abc123") assert row["client_id"] == "abc123" assert row["client_metadata"] == meta assert store.get_client(db, "nope") is None ``` - [ ] **Step 3: Run test to verify it fails** Run: `uv run pytest tests/mcp/test_oauth_store.py -v` Expected: FAIL (`ModuleNotFoundError` or `AttributeError: init_schema`). - [ ] **Step 4: Write minimal implementation** Create `src/mcp/oauth/store.py`: ```python import hashlib import json import sqlite3 from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path SCHEMA = """ CREATE TABLE IF NOT EXISTS oauth_clients ( client_id TEXT PRIMARY KEY, client_metadata TEXT NOT NULL, created_at TEXT NOT NULL ); 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 ); 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 ); CREATE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash); CREATE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash); """ def _utcnow_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def _hash(value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() @contextmanager def _connect(db_path): conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row try: yield conn finally: conn.close() def init_schema(db_path) -> None: Path(db_path).parent.mkdir(parents=True, exist_ok=True) with _connect(db_path) as conn: conn.executescript(SCHEMA) conn.commit() def create_client(db_path, client_id: str, metadata: dict) -> None: with _connect(db_path) as conn: conn.execute( "INSERT INTO oauth_clients (client_id, client_metadata, created_at) " "VALUES (?, ?, ?)", (client_id, json.dumps(metadata), _utcnow_iso()), ) conn.commit() def get_client(db_path, client_id: str) -> dict | None: with _connect(db_path) as conn: row = conn.execute( "SELECT * FROM oauth_clients WHERE client_id = ?", (client_id,) ).fetchone() if row is None: return None d = dict(row) d["client_metadata"] = json.loads(d["client_metadata"]) return d ``` - [ ] **Step 5: Run test to verify it passes** Run: `uv run pytest tests/mcp/test_oauth_store.py -v` Expected: PASS - [ ] **Step 6: Commit** ```bash pre-commit run --files src/mcp/oauth/__init__.py src/mcp/oauth/store.py tests/mcp/test_oauth_store.py git add src/mcp/oauth/__init__.py src/mcp/oauth/store.py tests/mcp/test_oauth_store.py git commit -m "feat(mcp): store OAuth — schéma + clients DCR (#114)" ``` --- ### Task 2: OAuth store — codes d'autorisation **Files:** - Modify: `src/mcp/oauth/store.py` - Test: `tests/mcp/test_oauth_store.py` **Interfaces:** - Consumes: `init_schema`, `_connect`, `_hash`, `_utcnow_iso` (Task 1). - Produces: - `save_code(db_path, code: str, *, client_id, user_id, redirect_uri, code_challenge, code_challenge_method, scope, resource, expires_at: str) -> None` - `get_code(db_path, code: str) -> dict | None` — clés : `client_id, user_id, redirect_uri, code_challenge, code_challenge_method, scope, resource, expires_at, used` - `delete_code(db_path, code: str) -> None` - [ ] **Step 1: Write the failing test** Append to `tests/mcp/test_oauth_store.py`: ```python def test_save_get_delete_code(tmp_path): db = tmp_path / "u.sqlite" store.init_schema(db) store.save_code( db, "thecode", client_id="abc", user_id=7, redirect_uri="https://claude.ai/api/mcp/auth_callback", code_challenge="chal", code_challenge_method="S256", scope="mcp", resource="https://colibre.fr/_mcp", expires_at="2999-01-01T00:00:00+00:00", ) row = store.get_code(db, "thecode") assert row["user_id"] == 7 assert row["code_challenge"] == "chal" assert row["resource"] == "https://colibre.fr/_mcp" store.delete_code(db, "thecode") assert store.get_code(db, "thecode") is None ``` - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/mcp/test_oauth_store.py::test_save_get_delete_code -v` Expected: FAIL (`AttributeError: save_code`). - [ ] **Step 3: Write minimal implementation** Append to `src/mcp/oauth/store.py`: ```python def save_code( db_path, code: str, *, client_id: str, user_id: int, redirect_uri: str | None, code_challenge: str | None, code_challenge_method: str | None, scope: str | None, resource: str | None, expires_at: str, ) -> None: with _connect(db_path) as conn: conn.execute( "INSERT INTO oauth_codes (code_hash, client_id, user_id, redirect_uri, " "code_challenge, code_challenge_method, scope, resource, expires_at) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ( _hash(code), client_id, user_id, redirect_uri, code_challenge, code_challenge_method, scope, resource, expires_at, ), ) conn.commit() def get_code(db_path, code: str) -> dict | None: with _connect(db_path) as conn: row = conn.execute( "SELECT * FROM oauth_codes WHERE code_hash = ?", (_hash(code),) ).fetchone() return dict(row) if row else None def delete_code(db_path, code: str) -> None: with _connect(db_path) as conn: conn.execute("DELETE FROM oauth_codes WHERE code_hash = ?", (_hash(code),)) conn.commit() ``` - [ ] **Step 4: Run test to verify it passes** Run: `uv run pytest tests/mcp/test_oauth_store.py -v` Expected: PASS - [ ] **Step 5: Commit** ```bash pre-commit run --files src/mcp/oauth/store.py tests/mcp/test_oauth_store.py git add src/mcp/oauth/store.py tests/mcp/test_oauth_store.py git commit -m "feat(mcp): store OAuth — codes d'autorisation (#114)" ``` --- ### Task 3: OAuth store — tokens (access/refresh, rotation, usage) **Files:** - Modify: `src/mcp/oauth/store.py` - Test: `tests/mcp/test_oauth_store.py` **Interfaces:** - Consumes: helpers de Task 1. - Produces: - `save_token(db_path, *, access_token, refresh_token, client_id, user_id, scope, resource, access_expires_at, refresh_expires_at) -> int` (renvoie l'id de ligne) - `get_token_by_access(db_path, access_token: str) -> dict | None` — clés dont `id, user_id, resource, access_expires_at, revoked_at` - `get_token_by_refresh(db_path, refresh_token: str) -> dict | None` - `revoke_token(db_path, token_id: int) -> None` - `increment_usage(db_path, token_id: int) -> None` - [ ] **Step 1: Write the failing test** Append to `tests/mcp/test_oauth_store.py`: ```python def test_save_get_revoke_token(tmp_path): db = tmp_path / "u.sqlite" store.init_schema(db) tid = store.save_token( db, access_token="acc", refresh_token="ref", client_id="abc", user_id=7, scope="mcp", resource="https://colibre.fr/_mcp", access_expires_at="2999-01-01T00:00:00+00:00", refresh_expires_at="2999-01-01T00:00:00+00:00", ) assert store.get_token_by_access(db, "acc")["id"] == tid assert store.get_token_by_refresh(db, "ref")["user_id"] == 7 store.increment_usage(db, tid) assert store.get_token_by_access(db, "acc")["count_total"] == 1 store.revoke_token(db, tid) assert store.get_token_by_access(db, "acc")["revoked_at"] is not None ``` - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/mcp/test_oauth_store.py::test_save_get_revoke_token -v` Expected: FAIL (`AttributeError: save_token`). - [ ] **Step 3: Write minimal implementation** Append to `src/mcp/oauth/store.py`: ```python def save_token( db_path, *, access_token: str, refresh_token: str | None, client_id: str, user_id: int, scope: str | None, resource: str, access_expires_at: str, refresh_expires_at: str | None, ) -> int: with _connect(db_path) as conn: cur = conn.execute( "INSERT INTO oauth_tokens (access_token_hash, refresh_token_hash, " "client_id, user_id, scope, resource, issued_at, access_expires_at, " "refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ( _hash(access_token), _hash(refresh_token) if refresh_token else None, client_id, user_id, scope, resource, _utcnow_iso(), access_expires_at, refresh_expires_at, ), ) conn.commit() return cur.lastrowid def get_token_by_access(db_path, access_token: str) -> dict | None: with _connect(db_path) as conn: row = conn.execute( "SELECT * FROM oauth_tokens WHERE access_token_hash = ?", (_hash(access_token),), ).fetchone() return dict(row) if row else None def get_token_by_refresh(db_path, refresh_token: str) -> dict | None: with _connect(db_path) as conn: row = conn.execute( "SELECT * FROM oauth_tokens WHERE refresh_token_hash = ?", (_hash(refresh_token),), ).fetchone() return dict(row) if row else None def revoke_token(db_path, token_id: int) -> None: with _connect(db_path) as conn: conn.execute( "UPDATE oauth_tokens SET revoked_at = ? WHERE id = ?", (_utcnow_iso(), token_id), ) conn.commit() def increment_usage(db_path, token_id: int) -> None: with _connect(db_path) as conn: conn.execute( "UPDATE oauth_tokens SET count_total = count_total + 1, last_used_at = ? " "WHERE id = ?", (_utcnow_iso(), token_id), ) conn.commit() ``` - [ ] **Step 4: Run test to verify it passes** Run: `uv run pytest tests/mcp/test_oauth_store.py -v` Expected: PASS (3 tests) - [ ] **Step 5: Commit** ```bash pre-commit run --files src/mcp/oauth/store.py tests/mcp/test_oauth_store.py git add src/mcp/oauth/store.py tests/mcp/test_oauth_store.py git commit -m "feat(mcp): store OAuth — tokens access/refresh + usage (#114)" ``` --- ### Task 4: Journal d'usage `mcp_usage` **Files:** - Create: `src/mcp/usage.py` - Test: `tests/mcp/test_usage.py` **Interfaces:** - Produces: - `init_schema(db_path) -> None` - `record(db_path, user_id: int, token_id: int, kind: str) -> None` — best-effort, ne lève jamais - `count_since(db_path, user_id: int, iso_ts: str) -> int` - `purge_older_than(db_path, days: int = 90) -> None` - [ ] **Step 1: Write the failing test** Create `tests/mcp/test_usage.py`: ```python from datetime import datetime, timedelta, timezone from src.mcp import usage def test_record_and_count_since(tmp_path): db = tmp_path / "u.sqlite" usage.init_schema(db) usage.record(db, user_id=7, token_id=1, kind="oauth") usage.record(db, user_id=7, token_id=1, kind="oauth") usage.record(db, user_id=9, token_id=2, kind="static") past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() assert usage.count_since(db, 7, past) == 2 assert usage.count_since(db, 9, past) == 1 def test_record_never_raises(tmp_path): # base non initialisée → record ne doit pas lever usage.record(tmp_path / "missing.sqlite", user_id=1, token_id=1, kind="oauth") def test_purge_older_than(tmp_path): db = tmp_path / "u.sqlite" usage.init_schema(db) old = (datetime.now(timezone.utc) - timedelta(days=200)).isoformat(timespec="seconds") import sqlite3 conn = sqlite3.connect(str(db)) conn.execute( "INSERT INTO mcp_usage (user_id, token_id, kind, created_at) VALUES (7, 1, 'oauth', ?)", (old,), ) conn.commit() conn.close() usage.purge_older_than(db, days=90) past = (datetime.now(timezone.utc) - timedelta(days=365)).isoformat() assert usage.count_since(db, 7, past) == 0 ``` - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/mcp/test_usage.py -v` Expected: FAIL (`ModuleNotFoundError: src.mcp.usage`). - [ ] **Step 3: Write minimal implementation** Create `src/mcp/usage.py`: ```python import sqlite3 from contextlib import contextmanager from datetime import datetime, timedelta, timezone from pathlib import Path SCHEMA = """ 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 ); CREATE INDEX IF NOT EXISTS idx_mcp_usage_user ON mcp_usage(user_id, created_at); """ def _utcnow_iso() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") @contextmanager def _connect(db_path): conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row try: yield conn finally: conn.close() def init_schema(db_path) -> None: Path(db_path).parent.mkdir(parents=True, exist_ok=True) with _connect(db_path) as conn: conn.executescript(SCHEMA) conn.commit() def record(db_path, user_id: int, token_id: int, kind: str) -> None: """Journalise une requête /_mcp authentifiée. Best-effort : n'échoue jamais.""" try: with _connect(db_path) as conn: conn.execute( "INSERT INTO mcp_usage (user_id, token_id, kind, created_at) " "VALUES (?, ?, ?, ?)", (user_id, token_id, kind, _utcnow_iso()), ) conn.commit() except sqlite3.Error: pass def count_since(db_path, user_id: int, iso_ts: str) -> int: with _connect(db_path) as conn: row = conn.execute( "SELECT COUNT(*) FROM mcp_usage WHERE user_id = ? AND created_at >= ?", (user_id, iso_ts), ).fetchone() return row[0] def purge_older_than(db_path, days: int = 90) -> None: cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat( timespec="seconds" ) with _connect(db_path) as conn: conn.execute("DELETE FROM mcp_usage WHERE created_at < ?", (cutoff,)) conn.commit() ``` - [ ] **Step 4: Run test to verify it passes** Run: `uv run pytest tests/mcp/test_usage.py -v` Expected: PASS (3 tests) - [ ] **Step 5: Commit** ```bash pre-commit run --files src/mcp/usage.py tests/mcp/test_usage.py git add src/mcp/usage.py tests/mcp/test_usage.py git commit -m "feat(mcp): journal d'usage mcp_usage (détection niveau 1, #114)" ``` --- ### Task 5: Documents de découverte (metadata RFC 9728 / 8414) **Files:** - Create: `src/mcp/oauth/metadata.py` - Test: `tests/mcp/test_oauth_metadata.py` **Interfaces:** - Produces: - `mcp_resource(base_url: str) -> str` → `f"{base}/_mcp"` - `protected_resource_metadata(base_url: str) -> dict` - `authorization_server_metadata(base_url: str) -> dict` - [ ] **Step 1: Write the failing test** Create `tests/mcp/test_oauth_metadata.py`: ```python from src.mcp.oauth import metadata BASE = "https://colibre.fr" def test_protected_resource_metadata(): m = metadata.protected_resource_metadata(BASE) assert m["resource"] == "https://colibre.fr/_mcp" assert m["authorization_servers"] == ["https://colibre.fr"] assert "offline_access" in m["scopes_supported"] def test_authorization_server_metadata(): m = metadata.authorization_server_metadata(BASE) assert m["issuer"] == "https://colibre.fr" assert m["authorization_endpoint"] == "https://colibre.fr/oauth/authorize" assert m["token_endpoint"] == "https://colibre.fr/oauth/token" assert m["registration_endpoint"] == "https://colibre.fr/oauth/register" assert m["code_challenge_methods_supported"] == ["S256"] assert m["token_endpoint_auth_methods_supported"] == ["none"] assert set(m["grant_types_supported"]) == {"authorization_code", "refresh_token"} assert "offline_access" in m["scopes_supported"] ``` - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/mcp/test_oauth_metadata.py -v` Expected: FAIL (`ModuleNotFoundError`). - [ ] **Step 3: Write minimal implementation** Create `src/mcp/oauth/metadata.py`: ```python SCOPES = ["mcp", "offline_access"] def mcp_resource(base_url: str) -> str: return f"{base_url.rstrip('/')}/_mcp" def protected_resource_metadata(base_url: str) -> dict: base = base_url.rstrip("/") return { "resource": mcp_resource(base), "authorization_servers": [base], "scopes_supported": SCOPES, "bearer_methods_supported": ["header"], } def authorization_server_metadata(base_url: str) -> dict: base = base_url.rstrip("/") return { "issuer": base, "authorization_endpoint": f"{base}/oauth/authorize", "token_endpoint": f"{base}/oauth/token", "registration_endpoint": f"{base}/oauth/register", "revocation_endpoint": f"{base}/oauth/revoke", "scopes_supported": SCOPES, "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "token_endpoint_auth_methods_supported": ["none"], "code_challenge_methods_supported": ["S256"], } ``` - [ ] **Step 4: Run test to verify it passes** Run: `uv run pytest tests/mcp/test_oauth_metadata.py -v` Expected: PASS - [ ] **Step 5: Commit** ```bash pre-commit run --files src/mcp/oauth/metadata.py tests/mcp/test_oauth_metadata.py git add src/mcp/oauth/metadata.py tests/mcp/test_oauth_metadata.py git commit -m "feat(mcp): documents de découverte OAuth (RFC 9728/8414, #114)" ``` --- ### Task 6: Gate abonnement & écrans de consentement **Files:** - Create: `src/mcp/oauth/consent.py` - Test: `tests/mcp/test_oauth_consent.py` **Interfaces:** - Consumes: `src.subscriptions.db.has_active_subscription(user_id) -> bool`, `src.utils.TOUS_ABONNES`. - Produces: - `subscription_ok(user_id: int) -> bool` — `TOUS_ABONNES or has_active_subscription(user_id)` - `render_subscription_required() -> str` — HTML - `render_consent(client_name: str, redirect_uri: str, scope: str) -> str` — HTML avec un `
" ) ``` Note : `markupsafe` est déjà une dépendance transitive (Flask/Jinja). - [ ] **Step 4: Run test to verify it passes** Run: `uv run pytest tests/mcp/test_oauth_consent.py -v` Expected: PASS (4 tests) - [ ] **Step 5: Commit** ```bash pre-commit run --files src/mcp/oauth/consent.py tests/mcp/test_oauth_consent.py git add src/mcp/oauth/consent.py tests/mcp/test_oauth_consent.py git commit -m "feat(mcp): gate abonnement + écrans de consentement OAuth (#114)" ``` --- ### Task 7: Configuration authlib (grants, DCR) **Files:** - Create: `src/mcp/oauth/server.py` - Test: `tests/mcp/test_oauth_server.py` (smoke : l'objet serveur se construit et enregistre les grants/endpoints) **Interfaces:** - Consumes: `store` (Tasks 1-3), `metadata.mcp_resource` (Task 5). - Produces: - `ACCESS_TTL = 3600`, `REFRESH_TTL = 5184000` - `create_authorization_server(app) -> AuthorizationServer` — configure `query_client`/`save_token`, enregistre `AuthorizationCodeGrant` (avec PKCE requis), `RefreshTokenGrant`, et l'endpoint DCR `ClientRegistrationEndpoint`. > **Note authlib 1.7.2 :** les noms de méthodes des mixins (`ClientMixin`, `TokenMixin`, `AuthorizationCodeMixin`) et de l'endpoint DCR sont fixés par la version installée. Le code ci-dessous suit l'API stable d'authlib 1.x ; la **Task 9** (test d'intégration bout-en-bout) est le contrat qui valide le câblage. Si un test échoue sur un nom de méthode, ajuster d'après le message d'erreur authlib. - [ ] **Step 1: Write the failing test** Create `tests/mcp/test_oauth_server.py`: ```python from flask import Flask from src.mcp.oauth import server def test_create_authorization_server(monkeypatch, tmp_path): monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "u.sqlite")) monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr") app = Flask(__name__) app.config["SECRET_KEY"] = "x" srv = server.create_authorization_server(app) assert srv is not None ``` - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/mcp/test_oauth_server.py -v` Expected: FAIL (`ModuleNotFoundError`). - [ ] **Step 3: Write minimal implementation** Create `src/mcp/oauth/server.py`: ```python import os import time from datetime import datetime, timedelta, timezone from authlib.integrations.flask_oauth2 import AuthorizationServer from authlib.oauth2.rfc6749 import grants from authlib.oauth2.rfc7591 import ClientRegistrationEndpoint from authlib.oauth2.rfc7636 import CodeChallenge from src.mcp.oauth import metadata, store ACCESS_TTL = 3600 # 1 h REFRESH_TTL = 5184000 # 60 j def _db() -> str: return os.environ["USERS_DB_PATH"] def _base() -> str: return os.getenv("APP_BASE_URL", "").rstrip("/") def _iso(epoch: float) -> str: return datetime.fromtimestamp(epoch, timezone.utc).isoformat(timespec="seconds") def _expired(iso_ts: str) -> bool: return datetime.fromisoformat(iso_ts) < datetime.now(timezone.utc) class _Client: def __init__(self, row: dict): self.client_id = row["client_id"] self.client_metadata = row["client_metadata"] def get_client_id(self): return self.client_id def get_default_redirect_uri(self): uris = self.client_metadata.get("redirect_uris") or [] return uris[0] if uris else None def get_allowed_scope(self, scope): if not scope: return "" allowed = set((self.client_metadata.get("scope") or "").split()) return " ".join(s for s in scope.split() if s in allowed) def check_redirect_uri(self, redirect_uri): return redirect_uri in (self.client_metadata.get("redirect_uris") or []) def check_client_secret(self, client_secret): return False # client public def check_endpoint_auth_method(self, method, endpoint): return method == "none" def check_response_type(self, response_type): return response_type == "code" def check_grant_type(self, grant_type): return grant_type in ("authorization_code", "refresh_token") def query_client(client_id): row = store.get_client(_db(), client_id) return _Client(row) if row else None def save_token(token, request): resource = ( request.form.get("resource") or request.args.get("resource") or metadata.mcp_resource(_base()) ) now = time.time() store.save_token( _db(), access_token=token["access_token"], refresh_token=token.get("refresh_token"), client_id=request.client.get_client_id(), user_id=int(request.user), scope=token.get("scope", ""), resource=resource, access_expires_at=_iso(now + token.get("expires_in", ACCESS_TTL)), refresh_expires_at=_iso(now + REFRESH_TTL), ) class _AuthCode: def __init__(self, row: dict): self._row = row self.user_id = row["user_id"] self.code_challenge = row["code_challenge"] self.code_challenge_method = row["code_challenge_method"] def get_redirect_uri(self): return self._row["redirect_uri"] def get_scope(self): return self._row["scope"] or "" class AuthorizationCodeGrant(grants.AuthorizationCodeGrant): TOKEN_ENDPOINT_AUTH_METHODS = ["none"] def save_authorization_code(self, code, request): store.save_code( _db(), code, client_id=request.client.get_client_id(), user_id=int(request.user), redirect_uri=request.redirect_uri, code_challenge=request.data.get("code_challenge"), code_challenge_method=request.data.get("code_challenge_method"), scope=request.scope, resource=request.data.get("resource") or metadata.mcp_resource(_base()), expires_at=_iso(time.time() + 300), ) def query_authorization_code(self, code, client): row = store.get_code(_db(), code) if ( row and row["client_id"] == client.get_client_id() and not _expired(row["expires_at"]) ): return _AuthCode(row) return None def delete_authorization_code(self, authorization_code): # code_challenge est unique par code ; on supprime par le hash côté store # via le code d'origine reconstruit impossible → on stocke le code clair # dans l'objet. Ici authorization_code provient de query ; on supprime par # la ligne complète. store.delete_code(_db(), authorization_code._raw_code) def authenticate_user(self, authorization_code): return authorization_code.user_id class RefreshTokenGrant(grants.RefreshTokenGrant): TOKEN_ENDPOINT_AUTH_METHODS = ["none"] INCLUDE_NEW_REFRESH_TOKEN = True # rotation def authenticate_refresh_token(self, refresh_token): row = store.get_token_by_refresh(_db(), refresh_token) if not row or row["revoked_at"] is not None: return None if row["refresh_expires_at"] and _expired(row["refresh_expires_at"]): return None return row def authenticate_user(self, credential): from src.mcp.oauth.consent import subscription_ok uid = credential["user_id"] # Re-vérifie l'abonnement au refresh : perdu → invalid_grant. if not subscription_ok(uid): return None return uid def revoke_old_credential(self, credential): store.revoke_token(_db(), credential["id"]) class _RegistrationEndpoint(ClientRegistrationEndpoint): def authenticate_token(self, request): # DCR ouverte (client public) : pas de jeton d'enregistrement requis. return True def resolve_public_key(self, request): return None def get_server_metadata(self): return metadata.authorization_server_metadata(_base()) def save_client(self, client_info, client_metadata, request): client_id = client_info["client_id"] store.create_client(_db(), client_id, dict(client_metadata)) return _Client( {"client_id": client_id, "client_metadata": dict(client_metadata)} ) def create_authorization_server(app) -> AuthorizationServer: server = AuthorizationServer(app, query_client=query_client, save_token=save_token) server.register_grant(AuthorizationCodeGrant, [CodeChallenge(required=True)]) server.register_grant(RefreshTokenGrant) server.register_endpoint(_RegistrationEndpoint) return server ``` > **Contrat de suppression de code (PKCE)** : pour que `delete_authorization_code` retrouve la ligne, `query_authorization_code` doit conserver le code clair. Ajouter dans `_AuthCode.__init__` : `self._raw_code = None`, puis dans `query_authorization_code`, après construction : `obj = _AuthCode(row); obj._raw_code = code; return obj`. (Le store indexe par hash ; on redonne le code clair reçu à `delete_code`.) Corriger `query_authorization_code` en conséquence : ```python def query_authorization_code(self, code, client): row = store.get_code(_db(), code) if ( row and row["client_id"] == client.get_client_id() and not _expired(row["expires_at"]) ): obj = _AuthCode(row) obj._raw_code = code return obj return None ``` - [ ] **Step 4: Run test to verify it passes** Run: `uv run pytest tests/mcp/test_oauth_server.py -v` Expected: PASS - [ ] **Step 5: Commit** ```bash pre-commit run --files src/mcp/oauth/server.py tests/mcp/test_oauth_server.py git add src/mcp/oauth/server.py tests/mcp/test_oauth_server.py git commit -m "feat(mcp): configuration authlib (grants + PKCE + DCR, #114)" ``` --- ### Task 8: Blueprint routes — well-known & DCR **Files:** - Create: `src/mcp/oauth/routes.py` - Test: `tests/mcp/test_oauth_routes_metadata.py` **Interfaces:** - Consumes: `metadata` (Task 5), `server.create_authorization_server` (Task 7), `consent` (Task 6), `store` (Tasks 1-3). - Produces: - `oauth_bp` (Blueprint) - `init_oauth(app) -> None` — crée le serveur authlib, enregistre les routes `/authorize` et `/token` sur le serveur, enregistre `oauth_bp` (well-known + `/oauth/register` + `/oauth/revoke`) sur `app`. - [ ] **Step 1: Write the failing test** Create `tests/mcp/test_oauth_routes_metadata.py`: ```python import pytest from flask import Flask @pytest.fixture def oauth_app(monkeypatch, tmp_path): from src.mcp.oauth import routes, store monkeypatch.setenv("USERS_DB_PATH", str(tmp_path / "u.sqlite")) monkeypatch.setenv("APP_BASE_URL", "https://colibre.fr") store.init_schema(tmp_path / "u.sqlite") app = Flask(__name__) app.config["SECRET_KEY"] = "x" routes.init_oauth(app) return app def test_protected_resource_wellknown(oauth_app): resp = oauth_app.test_client().get("/.well-known/oauth-protected-resource") assert resp.status_code == 200 assert resp.get_json()["resource"] == "https://colibre.fr/_mcp" def test_protected_resource_wellknown_mcp_suffix(oauth_app): resp = oauth_app.test_client().get("/.well-known/oauth-protected-resource/_mcp") assert resp.status_code == 200 assert resp.get_json()["resource"] == "https://colibre.fr/_mcp" def test_as_metadata_and_openid(oauth_app): for path in ( "/.well-known/oauth-authorization-server", "/.well-known/openid-configuration", ): resp = oauth_app.test_client().get(path) assert resp.status_code == 200 assert resp.get_json()["code_challenge_methods_supported"] == ["S256"] def test_dynamic_client_registration(oauth_app): resp = oauth_app.test_client().post( "/oauth/register", json={ "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"], "client_name": "Claude", "token_endpoint_auth_method": "none", }, ) assert resp.status_code == 201 assert "client_id" in resp.get_json() ``` - [ ] **Step 2: Run test to verify it fails** Run: `uv run pytest tests/mcp/test_oauth_routes_metadata.py -v` Expected: FAIL (`ModuleNotFoundError`). - [ ] **Step 3: Write minimal implementation** Create `src/mcp/oauth/routes.py`: ```python import os from flask import Blueprint, jsonify, request from src.mcp.oauth import metadata, server oauth_bp = Blueprint("mcp_oauth", __name__) _server = None # AuthorizationServer, initialisé par init_oauth def _base() -> str: return os.getenv("APP_BASE_URL", "").rstrip("/") @oauth_bp.route("/.well-known/oauth-protected-resource") @oauth_bp.route("/.well-known/oauth-protected-resource/_mcp") def protected_resource(): return jsonify(metadata.protected_resource_metadata(_base())) @oauth_bp.route("/.well-known/oauth-authorization-server") @oauth_bp.route("/.well-known/oauth-authorization-server/_mcp") @oauth_bp.route("/.well-known/openid-configuration") def as_metadata(): return jsonify(metadata.authorization_server_metadata(_base())) @oauth_bp.route("/oauth/register", methods=["POST"]) def register(): return _server.create_endpoint_response("client_registration") @oauth_bp.route("/oauth/revoke", methods=["POST"]) def revoke(): return _server.create_endpoint_response("revocation") def init_oauth(app) -> None: global _server _server = server.create_authorization_server(app) from src.mcp.oauth.authorize import authorize as _authorize # Task 9 from src.mcp.oauth.authorize import token as _token # Task 9 app.add_url_rule( "/oauth/authorize", "oauth_authorize", _authorize, methods=["GET", "POST"] ) app.add_url_rule("/oauth/token", "oauth_token", _token, methods=["POST"]) app.register_blueprint(oauth_bp) ``` > **Note :** `init_oauth` importe `authorize`/`token` depuis `src/mcp/oauth/authorize.py`, créé en Task 9. Pour faire passer les tests de CETTE tâche (qui ne touchent ni `/authorize` ni `/token`), créer d'abord un stub minimal `src/mcp/oauth/authorize.py` : > > ```python > def authorize(): > return "", 501 > > > def token(): > return "", 501 > ``` > > La Task 9 remplace ce stub par l'implémentation réelle. Also register the revocation endpoint in `server.py` `create_authorization_server` (ajout) : ```python from authlib.oauth2.rfc7009 import RevocationEndpoint class _RevocationEndpoint(RevocationEndpoint): def query_token(self, token_string, token_type_hint): return store.get_token_by_access(_db(), token_string) or store.get_token_by_refresh( _db(), token_string ) def revoke_token(self, token, request): store.revoke_token(_db(), token["id"]) server.register_endpoint(_RevocationEndpoint) ``` (Ajouter ces lignes dans `create_authorization_server` avant le `return server`.) - [ ] **Step 4: Run test to verify it passes** Run: `uv run pytest tests/mcp/test_oauth_routes_metadata.py -v` Expected: PASS (4 tests) - [ ] **Step 5: Commit** ```bash pre-commit run --files src/mcp/oauth/routes.py src/mcp/oauth/server.py src/mcp/oauth/authorize.py tests/mcp/test_oauth_routes_metadata.py git add src/mcp/oauth/routes.py src/mcp/oauth/server.py src/mcp/oauth/authorize.py tests/mcp/test_oauth_routes_metadata.py git commit -m "feat(mcp): routes découverte + DCR + révocation OAuth (#114)" ``` --- ### Task 9: Flux authorize + token (bout-en-bout, gate abonnement) **Files:** - Modify: `src/mcp/oauth/authorize.py` (remplace le stub) - Test: `tests/mcp/test_oauth_flow.py` **Interfaces:** - Consumes: `_server` de `routes` (via import), `consent` (Task 6), flask_login `current_user`. - Produces: - `authorize()` — GET : validation + login + gate abonnement + écran consentement ; POST : émission du code. - `token()` — délègue à authlib. - [ ] **Step 1: Write the failing test** Create `tests/mcp/test_oauth_flow.py`: ```python 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/