Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
19 KiB
Scope B2 — Serveur d'autorisation OAuth 2.1 pour le connecteur MCP
Issue : #114 (« Permettre l'accès aux données colibre via ChatGPT et Claude.ai »),
2ᵉ partie de #111.
Date : 2026-07-13
Statut : design approuvé
Prérequis : scopes A et B livrés et fusionnés dans dev (serveur MCP /_mcp,
src/mcp/, garde jeton statique src/mcp/auth.py, page /compte/mcp).
Objectif
Permettre aux clients grand public qui exigent OAuth — Claude.ai, Claude Desktop, Claude mobile, ChatGPT — de se connecter au serveur MCP colibre sans copier-coller de jeton, en faisant de colibre son propre serveur d'autorisation OAuth 2.1 conforme à la spec MCP (authorization, 2025-06-18 / 2025-11-25). L'accès reste conditionné à un abonnement colibre actif, vérifié à chaque requête.
Le chemin « jeton statique » livré en scope B (clients CLI : Claude Code, Gemini, Mistral) est conservé sans régression. Ce scope l'ajoute en parallèle.
Décisions de conception (arbitrées)
- colibre = serveur d'autorisation ET resource server, sur le même Flask
(
app.server) qui sert déjà/_mcp. L'étape de consentement réutilise la session flask_login existante. - authlib (déjà en dépendance pour LinkedIn) via son cœur OAuth 2.0
(
authlib.oauth2.rfc6749/7591/7636/8414), avec un stockage SQLite maison (sqlite3brut, commetokens_db.py/auth.db/subscriptions.db). Pas de SQLAlchemy. - Tokens d'accès opaques, validés par lookup haché en base (colibre étant AS
et RS sur le même hôte). Pas de JWT/JWKS/signature. L'audience (
resource) est stockée sur la ligne du token. - DCR (RFC 7591) comme baseline d'enregistrement client. Claude et ChatGPT le
supportent nativement et retombent dessus si CIMD n'est pas annoncé. Clients
publics (
token_endpoint_auth_methods_supported: ["none"]+ PKCE S256). - Gate abonnement bloquant tôt, à
/authorize, ET re-vérifié à chaque requête/_mcpet à chaque refresh (défense en profondeur — voir §« Abonnement »). - Détection d'usage (niveau 1) : table
mcp_usagejournalisant chaque requête/_mcpauthentifiée. Pas de rate-limiting actif (hors-périmètre). - Durées de vie : access token 1 h, refresh token 60 j, rotation du refresh à chaque usage (exigence client public).
Exigences externes vérifiées (2026-07)
Sources : spec MCP authorization (2025-06-18/2025-11-25), doc connecteurs Claude
(claude.com/docs/connectors/building/authentication), doc Apps SDK ChatGPT
(developers.openai.com/apps-sdk/build/auth).
Communes à Claude et ChatGPT :
- Découverte : PRM (RFC 9728) à
/.well-known/oauth-protected-resource(+ variante suffixée/_mcp) ; AS metadata (RFC 8414) à/.well-known/oauth-authorization-server;/.well-known/openid-configurationaussi (sondé par ChatGPT). Le champresourcede la PRM doit valoir exactement l'URL saisie par l'utilisateur (https://colibre.fr/_mcp) ;authorization_serversliste l'issuer, 1ʳᵉ entrée utilisée (pas de fallback vers les suivantes). - 401 +
WWW-Authenticate: Bearer …, resource_metadata="…": c'est ce header qui déclenche le flux OAuth côté client. Le 401 (pas 200) est requis. - PKCE S256 obligatoire ; metadata doit annoncer
code_challenge_methods_supported: ["S256"]. - Audience RFC 8707 :
resourceenvoyé sur/authorizeet/token, copié sur le token, validé à/_mcp. /tokenaccepteapplication/x-www-form-urlencoded, renvoie des codes d'erreur RFC 6749 (invalid_grant)./registerenapplication/json.- Refresh : Claude fait la rotation (client public) et n'ajoute
offline_accessque si annoncé dansscopes_supported. ChatGPT ne l'exige pas. → on supporte le refresh et on annonceoffline_access. - Redirect URIs validés en exact-match contre ceux fournis au DCR :
Claude
https://claude.ai/api/mcp/auth_callback; ChatGPThttps://chatgpt.com/connector/oauth/{id}(+ legacyhttps://chatgpt.com/connector_platform_oauth_redirect). Rien à coder en dur. - Consentement : l'écran doit afficher le hostname du redirect_uri (risque d'usurpation loopback, spec 2025-11-25).
- Latence : discovery/registration/token < 10 s, refresh < 30 s (les opérations sqlite sont bien en-deçà).
- Ops : l'AS et
/_mcpdoivent rester joignables depuis l'egress Anthropic160.79.104.0/21sans WAF bloquant, en HTTPS (localhost toléré en dev).
Architecture
Coexistence des deux chemins d'auth sur /_mcp
| Chemin | Clients | Mécanisme |
|---|---|---|
Jeton statique colibre_… (scope B, inchangé) |
Claude Code, Gemini, Mistral | collé à la main depuis /compte/mcp |
| OAuth 2.1 (ce scope B2) | Claude.ai, Claude Desktop/mobile, ChatGPT | « Ajouter un connecteur » → flux OAuth, zéro copier-coller |
Arborescence
src/mcp/oauth/
__init__.py
store.py # stores sqlite bruts : clients (DCR), codes, tokens
server.py # authlib AuthorizationServer : AuthorizationCodeGrant+PKCE, RefreshTokenGrant, DCR
metadata.py # documents JSON RFC 9728 (protected-resource) + RFC 8414 (AS)
consent.py # écran de consentement + gate abonnement
routes.py # blueprint Flask : /.well-known/*, /oauth/register, /oauth/authorize, /oauth/token, /oauth/revoke
src/mcp/usage.py # journal d'usage /_mcp (niveau 1 détection)
Fichiers modifiés : src/mcp/auth.py (garde /_mcp accepte aussi les tokens
OAuth + en-tête resource_metadata + journal mcp_usage), src/migrations.py
(nouvelles tables), src/pages/compte/mcp.py (instructions Claude.ai / ChatGPT),
src/app.py (enregistrement blueprint + exemption CSRF).
Isolation des unités
store.py: accès sqlite pur (clients/codes/tokens), testable sans HTTP ni authlib.server.py: configuration authlib (grants, hooksquery_client/save_token), branchée surstore.py.metadata.py: documents JSON purs (fonctions déterministes deAPP_BASE_URL).consent.py: rendu de l'écran + gate abonnement, testable indépendamment.usage.py: journal/_mcp, testable isolément.
Stockage — 3 tables OAuth + 1 table usage (users.sqlite)
Créées via _MIGRATIONS et initialisées au démarrage (init_schema, comme
tokens_db), avant apply_pending(). Tolérance duplicate déjà gérée.
oauth_clients—client_id(PK),client_metadata(JSON :redirect_uris,client_name,token_endpoint_auth_method='none',grant_types,scope),created_at. Clients publics, créés par DCR.oauth_codes—code_hash(PK),client_id,user_id,redirect_uri,code_challenge,code_challenge_method,scope,resource,expires_at,used. Éphémère (~60 s).oauth_tokens—access_token_hash,refresh_token_hash,client_id,user_id,scope,resource(audience),issued_at,access_expires_at(+1 h),refresh_expires_at(+60 j),revoked_at. Rotation du refresh à chaque usage.mcp_usage—id(PK),user_id,token_id,kind('static'|'oauth'),created_at. Une ligne par requête/_mcpauthentifiée (niveau 1 détection).
Les jetons statiques restent dans api_tokens (inchangé).
Endpoints, discovery & flux OAuth
Blueprint src/mcp/oauth/routes.py sur app.server, exempté de CSRF (comme
/_mcp ; POST externes sans jeton CSRF).
| Route | Méthode | Rôle |
|---|---|---|
/.well-known/oauth-protected-resource + …/oauth-protected-resource/_mcp |
GET | PRM (RFC 9728) : { resource:"<base>/_mcp", authorization_servers:[issuer], scopes_supported:["mcp","offline_access"] } |
/.well-known/oauth-authorization-server + /.well-known/openid-configuration |
GET | AS metadata (RFC 8414) : authorization_endpoint, token_endpoint, registration_endpoint, code_challenge_methods_supported:["S256"], token_endpoint_auth_methods_supported:["none"], grant_types_supported:["authorization_code","refresh_token"], scopes_supported:["mcp","offline_access"], issuer |
/oauth/register |
POST (JSON) | DCR (RFC 7591) : crée un oauth_clients public, renvoie client_id + metadata |
/oauth/authorize |
GET / POST | Consentement : login flask_login → gate abonnement → écran → code |
/oauth/token |
POST (form) | grants authorization_code (+PKCE) et refresh_token (rotation) |
/oauth/revoke |
POST (form) | RFC 7009 (révocation ; peu coûteux) |
Flux /authorize
server.get_consent_grant()(authlib) valideclient_id,redirect_uri(exact-match),resource,code_challenge.current_usernon authentifié →redirect('/connexion?next=<authorize-url>'), retour ici après login.- Connecté mais pas d'abonnement actif (
TOUS_ABONNES or has_active_subscription(user_id)faux) → page HTML « Abonnement requis » + lien/compte/abonnement, aucun code émis. - Sinon → écran de consentement minimal : nom du client, hostname du redirect_uri, périmètre (« lire les données colibre en votre nom »), boutons Autoriser / Refuser.
- Autoriser →
server.create_authorization_response(grant_user=current_user):code_hashstocké dansoauth_codes(avecresource,code_challenge), redirection vers le client.
Flux /token
authorization_code: authlib échange code +code_verifier(PKCE S256) →oauth_tokens(access 1 h, refresh 60 j,resourcecopié comme audience). Code invalide /code_verifiererroné →invalid_grant.refresh_token: rotation (ancien refresh révoqué, nouveau renvoyé dans la même réponse), et re-vérification de l'abonnement : si perdu →invalid_grant, ce qui force le client à relancer le flux complet (lequel rebute au gate/authorize).
Garde /_mcp unifié (src/mcp/auth.py)
- 401 enrichi :
WWW-Authenticate: Bearer realm="colibre-mcp", resource_metadata="<base>/.well-known/oauth-protected-resource/_mcp". - Routage du Bearer :
- préfixe
colibre_→ chemin statique existant (api_tokens, inchangé) ; - sinon → chemin OAuth (
oauth_tokens: lookup haché, non expiré, audience ==<base>/_mcp, non révoqué).
- préfixe
- Convergence :
user_id→ abonnement actif →increment_usage→mcp_usage.record(user_id, token_id, kind)(best-effort) → laisser passer. - Échecs : token invalide / expiré / mauvaise audience / révoqué → 401 (avec
le header) ;
user_idnul ou abonnement inactif → 403. Rien n'est journalisé dansmcp_usagesur échec.
Abonnement : sémantique TOUS_ABONNES et « pas de droit acquis »
TOUS_ABONNES est lu au démarrage (constante d'os.getenv). L'abonnement est
re-vérifié à chaque requête /_mcp, pas seulement à l'émission du token :
| Moment | Effet sur un token OAuth existant |
|---|---|
TOUS_ABONNES=true |
Tout utilisateur connecté franchit /authorize, obtient un token ; chaque requête /_mcp passe. |
TOUS_ABONNES → false (après redémarrage) |
Token ni supprimé ni révoqué, mais à la requête suivante le garde ré-évalue False or has_active_subscription(user_id). Sans abonnement actif → 403 immédiat ; le token devient inerte. |
| L'utilisateur s'abonne ensuite | Le même token refonctionne (garde de nouveau vrai), sans re-login. |
Il n'existe aucune fenêtre de tolérance : le check étant par requête, même un
token émis juste avant le basculement est bloqué dès l'appel suivant. Cohérent avec
le refresh (invalid_grant si abonnement perdu).
Détection d'usage — niveau 1 (src/mcp/usage.py)
Objectif : rendre l'usage /_mcp interrogeable (socle d'un futur quota /
rate-limiting), sans encore plafonner.
record(db_path, user_id, token_id, kind): insère(user_id, token_id, kind, created_at)dansmcp_usage. Appelé par le garde après un succès. Best-effort (jamais bloquant).count_since(db_path, user_id, iso_ts) -> int: nombre de requêtes d'un utilisateur depuis un horodatage (base d'un futur seuil/minute).purge_older_than(db_path, days=90): suppression des lignes anciennes, appelée au démarrage (mirror dedb.purge_expired_tokens()), pour borner la croissance.
Signaux de détection disponibles au total après ce scope :
- Matomo (déjà câblé, scope A) : volume par tool dans le temps (anonyme).
- Compteurs par token :
count_total/last_used_at(api_tokens+oauth_tokens), cumulatifs. mcp_usage: journal par requête, fenêtrable par utilisateur.- Logs d'accès gunicorn/nginx : brut, par IP.
UI /compte/mcp (src/pages/compte/mcp.py)
La page conserve son générateur de jetons statiques ; l'accordéon client_instructions
évolue :
- Claude Code / Gemini / Mistral : inchangés (jeton statique collé).
- Claude.ai / Desktop / mobile : nouvelle entrée « Aucun jeton à copier » →
Paramètres → Connecteurs → Ajouter un connecteur personnalisé → URL
https://colibre.fr/_mcp→ se connecter avec colibre → Autoriser. Champ « Client Secret » laissé vide (client public). - ChatGPT : remplace le texte « votez pour la fonctionnalité » par les étapes
réelles (connecteurs → ajouter par URL
https://colibre.fr/_mcp→ flux OAuth) ; note que la disponibilité dépend du plan ChatGPT de l'utilisateur. - Court paragraphe expliquant : jeton = clients CLI, OAuth = apps grand public, même abonnement requis.
Les tokens OAuth (éphémères, gérés par le client) ne sont pas listés dans le tableau, qui reste réservé aux jetons statiques.
Activation & configuration
- Toujours piloté par
DASH_MCP_ENABLED=true(le flux OAuth ne s'enregistre que dans ce bloc deapp.py, aprèsconfigure_mcp_server). APP_BASE_URLsert d'issuer et à construireresource/ URLs well-known. HTTPS obligatoire hors dev (exigence spec) ; localhost toléré en dev.- Aucun nouveau secret (tokens opaques hachés ;
SECRET_KEYdéjà présent). .template.env: documenter que le flux OAuth requiertAPP_BASE_URLen HTTPS et que l'egress Anthropic160.79.104.0/21doit être joignable.- Déploiement recommandé : activer d'abord sur
test.colibre.fr(branchedev), valider un connecteur Claude.ai réel, puismain.
Stratégie de test
tests/mcp/test_oauth_metadata.py: PRM (resourceexact,authorization_servers,scopes_supportedinclutoffline_access) ; AS metadata (code_challenge_methods_supported:["S256"],none,registration_endpoint, grants) ;/.well-known/openid-configuration= miroir ; variantes suffixées/_mcp.tests/mcp/test_oauth_store.py: CRUD clients/codes/tokens, hachage, expiration, rotation refresh, révocation.tests/mcp/test_oauth_flow.py: DCR → client public ;/authorize(non connecté → redirection ; connecté sans abonnement → « Abonnement requis », aucun code ; avec abonnement → code) ;/tokenavec PKCE S256 (mauvaiscode_verifier→invalid_grant) ;resource/audience copiée ; redirect_uri non enregistrée → rejet.tests/mcp/test_auth.py(étendre) : garde accepte un token OAuth valide ; rejette expiré / mauvaise audience / révoqué (401 +resource_metadata) ;TOUS_ABONNES=false+ pas d'abonnement → 403 même avec token OAuth valide ; refresh refusé (invalid_grant) si abonnement perdu.tests/mcp/test_usage.py(nouveau) :recordinsère sur succès ; aucune insertion sur 401/403 ; fenêtrage decount_since;purge_older_than.- Migration :
apply_pending()idempotente (DB existante → ajoute les tables ; DB fraîche → tolère duplicate).
Hors-périmètre (YAGNI → itérations futures)
- CIMD (Client ID Metadata Document, spec MCP 2025-11-25) et redirect loopback port-agnostic (Claude Code passe déjà par jeton statique).
oauth_anthropic_creds,private_key_jwt, mTLS,id_token_hint, tokens JWT/JWKS.- Rate-limiting / quotas / 429 / alerting (niveaux 2-3) :
mcp_usageen pose le socle, le plafonnement effectif fera l'objet d'une issue dédiée. - Scopes fins par tool ; écran de gestion/révocation des connexions OAuth actives côté utilisateur.
- Soumission au directory de connecteurs Claude / ChatGPT.