ITSkillsCenter
E-commerce

Sécuriser un serveur MCP en OAuth 2.1 avec Keycloak pas-à-pas

16 دقائق للقراءة

📍 Guide principal : Créer un serveur MCP : architecture, primitives, premier déploiement. Ce tutoriel suppose un serveur MCP fonctionnel en HTTP ; la construction du serveur est traitée dans le guide principal.

Tant qu’un serveur MCP tourne en stdio sur la machine de l’utilisateur, la sécurité repose sur l’isolation OS. Dès que ce même serveur passe en HTTP — pour être consommé par plusieurs personnes, par une CI, par un agent dans le cloud — le problème change radicalement de nature. Sans protection, un endpoint public est une invitation à exécuter des outils arbitraires sous l’identité de votre serveur. La spécification MCP 2025-11-25 a tranché : pour les transports HTTP, l’autorisation se fait par OAuth 2.1, avec PKCE obligatoire, validation d’audience stricte, et découverte automatique via Protected Resource Metadata. Ce tutoriel implémente le flux complet, étape par étape, sur un serveur Python avec Keycloak comme autorisation server local.

Pourquoi Keycloak ? Parce qu’il est gratuit, supporte OAuth 2.0/OIDC avec Dynamic Client Registration et introspection de tokens (RFC 7662), et propose une intégration documentée comme autorisation server MCP. Note : RFC 8707 Resource Indicators n’est pas encore pleinement implémenté côté Keycloak ; l’audience est embarquée via un mapper sur le scope, ce qui atteint le même résultat fonctionnel, et qu’il tourne dans un conteneur Docker en quelques secondes. Ce que vous apprendrez ici fonctionne tel quel avec Auth0, Okta, Authentik, Keycloak managé ou n’importe quel autorisation server compatible — c’est précisément l’intérêt d’avoir choisi un standard. Comptez 60 minutes pour parcourir le tutoriel et arriver à un serveur MCP qui refuse les requêtes non authentifiées et accepte les tokens validés.

Prérequis

  • Docker Desktop ou Docker Engine
  • Python 3.11+ avec un environnement virtuel
  • Un serveur MCP déjà fonctionnel en local (le tutoriel reprend les étapes pour ceux qui partent de zéro)
  • VS Code avec l’extension MCP, ou Claude Desktop, pour tester en bout de chaîne
  • Niveau attendu : intermédiaire à avancé (notions OAuth utiles)
  • Temps estimé : 60 minutes

Étape 1 — Comprendre le flux d’autorisation MCP

Avant de coder, posez le décor mentalement, sinon vous chercherez la cause d’erreurs subtiles pendant des heures. Le flux MCP suit six étapes invariantes. Le client envoie une requête sans token. Le serveur répond 401 Unauthorized avec un en-tête WWW-Authenticate: Bearer realm="mcp", resource_metadata="..." qui pointe vers un document Protected Resource Metadata. Le client fetche ce document, y découvre l’URL de l’autorisation server. Il interroge le metadata de l’autorisation server (RFC 8414 ou OIDC Discovery), obtient les endpoints /authorize et /token. Il enregistré dynamiquement un client si nécessaire (Dynamic Client Registration, RFC 7591), puis lance le flux OAuth 2.1 authorization code avec PKCE. Une fois le token reçu, il rejoue sa requête initiale avec Authorization: Bearer ..., et le serveur valide le token avant de servir.

Trois validations cruciales côté serveur, oubliées dans neuf serveurs amateurs sur dix : la signature du token doit être vérifiée auprès de l’autorisation server (introspection RFC 7662 ou validation JWT locale), la claim aud du token doit correspondre exactement à l’URL de votre serveur (sinon un attaquant peut rejouer un token volé d’un autre service), et les scopes doivent couvrir les outils invoqués. Sans ces trois validations, le serveur est ouvert.

Étape 2 — Lancer Keycloak en local

On démarre une instance Keycloak en mode développement. Cette commande tirée de la documentation officielle MCP suffit pour avoir un autorisation server complet sur localhost:8080, avec un compte admin admin/admin.

docker run -p 127.0.0.1:8080:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak start-dev

Le téléchargement de l’image prend une minute la première fois. Au démarrage, vous verrez « Keycloak X.Y.Z on JVM started in Xs » dans les logs. Ouvrez http://localhost:8080 dans le navigateur, cliquez « Administration Console », loggez-vous avec admin/admin. Vous êtes dans le realm master par défaut — pour ce tutoriel, on l’utilise tel quel ; en production vous créeriez un realm dédié.

Cette configuration n’est pas pour la production. Le mode start-dev désactive HTTPS, utilise une base mémoire qui se vide au redémarrage, et expose des défauts non sécurisés. Pour un déploiement réel, suivez le guide officiel Configuring Keycloak for production et déployez derrière un reverse proxy avec TLS terminé.

Étape 3 — Créer le scope mcp:tools dans Keycloak

Le scope est ce qui matérialise « le droit d’invoquer les outils MCP ». On en crée un dédié pour pouvoir l’attacher aux clients qui en ont besoin. Dans la console Keycloak, naviguez : Client scopes → Create client scope. Nom : mcp:tools. Type : Default. Cochez Include in token scope. Sauvegardez.

Ouvrez le scope fraîchement créé, allez dans l’onglet Mappers → Configure a new mapper → Audience. Nom : audience-config. Included Custom Audience : http://localhost:3000 (l’URL future de votre serveur MCP). Cette étape est critique : sans audience embarquée dans le token, votre serveur MCP n’aura aucun moyen de vérifier que le token lui était bien destiné, et la spec lui demandera de le rejeter.

Étape 4 — Autoriser le Dynamic Client Registration

Pour que des clients MCP comme VS Code puissent s’enregistrer automatiquement sans intervention manuelle, Keycloak doit l’autoriser depuis votre machine. Clients → Client registration → Anonymous Access Policies → Trusted Hosts. Désactivez Client URIs Must Match, ajoutez l’IP de votre machine (visible dans les logs Keycloak quand un test échoue, ou via ipconfig/ifconfig). Sauvegardez.

En production, vous remplacez ce mode permissif par une politique d’enregistrement contrôlée — chaque client MCP est pré-enregistré manuellement, ou DCR est gardé derrière un Initial Access Token. Ouvrir le DCR au public reviendrait à laisser n’importe qui créer un client OAuth dans votre realm.

Étape 5 — Créer le client de service du serveur MCP

Votre serveur MCP a lui-même besoin d’un client OAuth pour appeler l’endpoint d’introspection (vérifier les tokens entrants). Clients → Create client. Client ID : mcp-server. Cliquez Next, activez Client authentication, Next, Save. Ouvrez l’onglet Credentials et notez le Client Secret — vous en aurez besoin dans la config Python.

Ce secret est l’équivalent d’un mot de passe : ne le commitez jamais. On le mettra dans une variable d’environnement, lue par le serveur au démarrage. En production, stockez-le dans un secret manager (HashiCorp Vault, AWS Secrets Manager, Doppler) et injectez-le au runtime sans qu’il transite par le repo.

Étape 6 — Préparer le projet Python avec FastMCP

On crée le projet et on installe les dépendances. mcp[cli] embarque la couche d’auth, httpx sert pour appeler l’endpoint d’introspection Keycloak, python-dotenv charge les variables.

mkdir mcp-secured && cd mcp-secured
python -m venv .venv
source .venv/bin/activate  # Windows : .venv\Scripts\activate
pip install "mcp[cli]" httpx python-dotenv pydantic

L’installation prend trente secondes. Si mcp[cli] remonte une erreur de résolution de version, mettez à jour pip d’abord (pip install -U pip) puis relancez — la couche auth a été ajoutée dans des versions récentes du SDK et un pip ancien peut échouer à résoudre les contraintes.

Créez le fichier .env à la racine, avec la configuration que Keycloak a affichée :

HOST=localhost
PORT=3000
AUTH_HOST=localhost
AUTH_PORT=8080
AUTH_REALM=master
OAUTH_CLIENT_ID=mcp-server
OAUTH_CLIENT_SECRET=<le-secret-note-a-l-etape-5>
MCP_SCOPE=mcp:tools

Ajoutez immédiatement .env et .venv/ à un .gitignore. C’est l’erreur la plus commune sur les serveurs MCP partagés en open source : un secret OAuth qui se retrouve dans l’historique Git d’un dépôt public, indexé par les bots de scan en quelques minutes.

Étape 7 — Écrire le vérifier de tokens par introspection

Le vérifier est le composant qui prend le token entrant, l’envoie à Keycloak pour validation, et retourne soit un objet AccessToken exploitable, soit None si le token est invalide. Le SDK MCP fournit l’interface TokenVerifier à implémenter. On utilise l’introspection (RFC 7662) plutôt que la validation JWT locale, car elle permet de vérifier en plus la révocation côté autorisation server — un token compromis peut être invalidé immédiatement sans attendre son expiration naturelle.

Créez token_verifier.py :

import httpx
from typing import Any
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url


class IntrospectionTokenVerifier(TokenVerifier):
    def __init__(self, introspection_endpoint: str, server_url: str,
                 client_id: str, client_secret: str):
        self.endpoint = introspection_endpoint
        self.server_url = server_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.resource_url = resource_url_from_server_url(server_url)

    async def verify_token(self, token: str) -> AccessToken | None:
        if not self.endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")):
            return None
        async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
            try:
                resp = await client.post(
                    self.endpoint,
                    data={"token": token, "client_id": self.client_id,
                          "client_secret": self.client_secret},
                    headers={"Content-Type": "application/x-www-form-urlencoded"},
                )
                if resp.status_code != 200:
                    return None
                data = resp.json()
                if not data.get("active", False):
                    return None
                if not self._validate_audience(data):
                    return None
                return AccessToken(
                    token=token,
                    client_id=data.get("client_id", "unknown"),
                    scopes=data.get("scope", "").split() if data.get("scope") else [],
                    expires_at=data.get("exp"),
                    resource=data.get("aud"),
                )
            except Exception:
                return None

    def _validate_audience(self, data: dict[str, Any]) -> bool:
        aud = data.get("aud")
        if not aud:
            return False
        candidates = aud if isinstance(aud, list) else [aud]
        return any(check_resource_allowed(self.resource_url, a) for a in candidates)

Trois protections importantes dans ce code. Le check startswith empêche d’envoyer un token à un endpoint d’introspection arbitraire (mitigation SSRF). Le rejet quand active=false couvre les tokens révoqués ou expirés. Et la validation d’audience refuse les tokens qui n’étaient pas destinés à ce serveur — c’est précisément ce qui empêche un attaquant de rejouer un token volé d’une autre application qui partagerait le même autorisation server.

Étape 8 — Créer le serveur MCP protégé

Le serveur lui-même reste très proche d’un serveur FastMCP classique, mais avec deux paramètres en plus dans le constructeur : token_verifier et auth. Ces deux blocs activent la protection automatique de tous les outils — toute requête sans token valide reçoit un 401 avec l’en-tête WWW-Authenticate conforme à la spec.

Créez server.py :

import os
from urllib.parse import urljoin
from dotenv import load_dotenv
from pydantic import AnyHttpUrl
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp.server import FastMCP
from token_verifier import IntrospectionTokenVerifier

load_dotenv()
HOST = os.environ.get("HOST", "localhost")
PORT = int(os.environ.get("PORT", 3000))
AUTH_BASE = f"http://{os.environ['AUTH_HOST']}:{os.environ['AUTH_PORT']}/realms/{os.environ['AUTH_REALM']}/"
SERVER_URL = f"http://{HOST}:{PORT}"

verifier = IntrospectionTokenVerifier(
    introspection_endpoint=urljoin(AUTH_BASE, "protocol/openid-connect/token/introspect"),
    server_url=SERVER_URL,
    client_id=os.environ["OAUTH_CLIENT_ID"],
    client_secret=os.environ["OAUTH_CLIENT_SECRET"],
)

mcp = FastMCP(
    name="MCP secured server",
    host=HOST,
    port=PORT,
    streamable_http_path="/",
    token_verifier=verifier,
    auth=AuthSettings(
        issuer_url=AnyHttpUrl(AUTH_BASE),
        required_scopes=[os.environ.get("MCP_SCOPE", "mcp:tools")],
        resource_server_url=AnyHttpUrl(SERVER_URL),
    ),
)

@mcp.tool()
async def echo(message: str) -> dict:
    """Renvoie le message recu, prouve que l'auth est en place."""
    return {"echo": message}

if __name__ == "__main__":
    mcp.run(transport="streamable-http")

Le passage required_scopes=["mcp:tools"] impose que tout token entrant ait le scope mcp:tools — sans lui, même un token valide est refusé. C’est le mécanisme de scoping fin recommandé par la spec : on découpe les permissions outil par outil ou groupe d’outils par groupe d’outils, plutôt que d’avoir un scope catch-all qui donnerait tous les droits.

Étape 9 — Vérifier que les requêtes non authentifiées sont rejetées

Lancez le serveur et testez immédiatement avec curl, sans token, pour vérifier que la protection est bien en place :

python server.py
curl -i -X POST http://localhost:3000/ \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'

Vous devez voir une réponse HTTP/1.1 401 Unauthorized avec l’en-tête WWW-Authenticate: Bearer realm="mcp", resource_metadata="http://localhost:3000/.well-known/oauth-protected-resource". C’est exactement ce que la spec MCP exige. Si vous obtenez 200 ou un autre code, la protection n’est pas active — relisez les paramètres token_verifier et auth dans FastMCP().

Vérifiez aussi que le document Protected Resource Metadata est servi :

curl http://localhost:3000/.well-known/oauth-protected-resource

Vous devez recevoir un JSON listant les autorisation servers reconnus, les scopes supportés, et l’URL de la ressource. C’est ce document qu’un client MCP fetchera automatiquement après le 401 pour découvrir où s’authentifier.

Étape 10 — Tester avec VS Code

VS Code (depuis la version qui intègre MCP, courant 2025) implémente le flux d’autorisation OAuth 2.1 nativement. Ouvrez la palette (Ctrl+Shift+P), tapez « MCP: Add server ». Choisissez HTTP, entrez l’URL http://localhost:3000, donnez un nom (par exemple mcp-secured-test).

VS Code fait automatiquement la requête initiale, reçoit le 401, lit le PRM, découvre Keycloak, s’enregistre via DCR, ouvre votre navigateur sur la page de consentement Keycloak. Vous vous loggez (admin/admin), vous accordez le scope mcp:tools, vous êtes redirigé vers VS Code, et le client peut maintenant invoquer l’outil echo. Tapez #echo "hello" dans le chat Copilot et la réponse doit revenir avec le message — preuve que la chaîne complète fonctionne.

Si la consentement échoue, regardez les logs du conteneur Keycloak avec docker logs <container>. Les erreurs typiques : audience non configurée (le token est émis mais sans aud donc votre serveur le refuse), scope mcp:tools non assigné par défaut, ou Trusted Host de votre IP non autorisé pour DCR. Les trois cas sont des reglages dans la console Keycloak, pas du code à changer.

Erreurs fréquentes

ErreurCauseSolution
200 OK sur requête sans tokentoken_verifier ou auth manquantVérifier les deux paramètres du constructeur FastMCP
Token rejeté avec audience valideURL serveur dans la config Keycloak ne correspond pas exactementAligner aud Keycloak et SERVER_URL Python (sans / final)
VS Code reste bloqué sur « connecting »Trusted Host non configuré pour DCRAjouter votre IP locale dans Keycloak Client Registration
Token expiré au milieu d’une sessionTokens trop courtsActiver le refresh token côté client ou ajuster Access Token Lifespan Keycloak
Scope refusé alors qu’accordéInclude in token scope non coché sur le scopeCocher la case dans la config du scope Keycloak
500 sur introspectionClient secret incorrect dans .envRégénérer dans Keycloak et mettre à jour .env

Bonnes pratiques pour la production

Le tutoriel utilise du HTTP en local pour aller à l’essentiel. En production, sept règles s’appliquent. HTTPS partout, sans exception : la spec MCP interdit les tokens en clair sur HTTP en dehors de localhost. Tokens courts : 15 minutes maximum, avec refresh token pour renouveler — un token volé doit avoir une fenêtre d’utilisation minimale. Scopes granulaires : un scope par outil sensible (par exemple mcp:read, mcp:write, mcp:admin) plutôt qu’un seul scope générique.

Ne loggez jamais les tokens ni les en-têtes Authorization. Filtrez vos logs structurés pour redacter ces champs avant qu’ils n’atteignent un agrégateur. Bibliothèque de validation testée : ne réimplémentez pas la validation JWT à la main, utilisez le SDK MCP ou une lib reconnue (PyJWT, jose). Réponses d’erreur génériques : retournez « invalid token » au client, mais loggez le détail (raison exacte, corrélation ID) en interne — ne fuitez pas les internes au monde extérieur. Audit des invocations : tracez qui (client_id) a appelé quel outil avec quels paramètres, avec un identifiant de corrélation pour relier un appel client à une trace serveur.

Pour aller plus loin

FAQ

Faut-il OAuth pour tous les serveurs MCP ?

Non. Pour les serveurs en stdio sur la machine de l’utilisateur, l’isolation OS suffit et OAuth est inutilement complexe. La spec recommande OAuth uniquement pour les serveurs HTTP exposés à plusieurs utilisateurs ou destinés à des environnements professionnels.

Puis-je utiliser un simple bearer token statique ?

Techniquement oui pour des cas internes très simples, mais ce n’est pas conforme à la spec MCP. Vous perdez la révocation, la rotation, le scoping fin, la traçabilité. Pour un MVP rapide, c’est acceptable ; pour de la production multi-utilisateurs, OAuth est l’option qui passe à l’échelle.

Quelle alternative à Keycloak ?

Toute autorisation server OAuth 2.1 fonctionne : Auth0, Okta, Authentik, ZITADEL, Ory Hydra, FusionAuth. Le code Python du vérifier reste identique, seules les URLs des endpoints changent. Pour un usage perso, Authentik en self-hosted est une bonne alternative open source légère.

Les tokens JWT sont-ils plus rapides que l’introspection ?

Oui — la validation JWT locale évite l’aller-retour réseau vers l’autorisation server. Mais on perd la possibilité de révoquer en temps réel. Le bon compromis : validation JWT locale en cache court (30 secondes), repli sur introspection au-delà. C’est ce que fait par exemple le middleware requireBearerAuth du SDK TypeScript MCP en mode hybride.

Comment révoquer un token compromis ?

Dans Keycloak, on désactive la session côté Sessions ou on révoque le token via l’endpoint /protocol/openid-connect/revoke. Si vous validez par introspection, le serveur MCP commencera à refuser ce token au prochain appel. Si vous validez par JWT local sans cache, c’est immédiat ; avec cache, dans la fenêtre du cache.

Quel access token lifespan recommander ?

5 à 15 minutes pour des tokens d’accès, avec un refresh token longue durée (8h à 30j selon le contexte). Ne dépassez jamais 1 heure pour un access token en MCP — la fenêtre de risque devient trop grande en cas de fuite.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité