Aller au contenu
Développement medium

Authentification MCP avec OAuth 2.1 et authentik

15 min de lecture

logo python

Un serveur MCP en transport stdio est protégé par construction : seul le processus parent peut lui parler. Dès que vous l'exposez en HTTP, il devient joignable par n'importe qui sur le réseau — et il exécute des actions. Il lui faut une porte. Ce guide protège un serveur MCP avec OAuth 2.1, selon la spécification d'autorisation MCP : authentik joue le serveur d'autorisation, le serveur MCP devient un Resource Server qui vérifie les jetons sans jamais en émettre. Vous déploierez authentik, configurerez un provider OAuth2, écrirez un serveur MCP qui valide les JWT via la JWKS de l'émetteur, puis vérifierez les quatre comportements attendus — refus sans jeton, refus si invalide, refus si la portée manque, accès si tout est correct. Public visé : développeur ayant déjà écrit un serveur MCP HTTP.

  • Comprendre pourquoi un serveur MCP HTTP exige une authentification.
  • Distinguer les trois rôles OAuth 2.1 : serveur d'autorisation, Resource Server, client.
  • Déployer authentik comme serveur d'autorisation et y configurer un provider OAuth2.
  • Protéger un serveur MCP avec un TokenVerifier qui valide les JWT via JWKS.
  • Obtenir un jeton et distinguer les grants client_credentials et authorization_code.

Ce guide prolonge la série MCP. Il suppose acquis :

Le risque tient à une bascule de modèle de menace. En stdio, le serveur MCP est un sous-processus : son seul interlocuteur est l'hôte qui l'a lancé. En HTTP — transport Streamable HTTP — le serveur écoute sur un port, et tout client qui connaît l'URL peut appeler ses tools. Or un tool n'est pas une lecture inoffensive : il agit. Exposer un serveur MCP HTTP sans authentification, c'est publier une API d'actions en accès libre.

La spécification d'autorisation MCP répond à ce besoin en réutilisant OAuth 2.1 — pas un mécanisme maison. Elle pose une règle claire : le serveur MCP ne gère ni mots de passe, ni comptes. Il délègue l'authentification à un serveur d'autorisation dédié et se contente de vérifier les jetons que celui-ci émet. Cette séparation est le cœur du sujet : un serveur MCP bien conçu en sécurité est un serveur MCP qui ne fait pas d'authentification lui-même.

Les trois rôles : qui authentifie, qui vérifie, qui appelle

Section intitulée « Les trois rôles : qui authentifie, qui vérifie, qui appelle »

OAuth 2.1 répartit la responsabilité entre trois acteurs. Les confondre est la première source d'erreur ; les séparer rend l'architecture limpide.

RôleTenu parResponsabilité
Serveur d'autorisationauthentikAuthentifie l'utilisateur ou la machine, émet les access tokens
Resource ServerLe serveur MCPVérifie les jetons, expose les tools aux seuls appelants valides
ClientClaude Desktop, un script, un agentObtient un jeton, l'envoie à chaque requête

Le serveur MCP n'est jamais le serveur d'autorisation. Il ne stocke aucun secret d'utilisateur, ne gère aucune session de connexion. Il reçoit un jeton dans l'en-tête Authorization: Bearer, en vérifie la validité cryptographique et la portée, puis laisse passer — ou refuse. Tout le travail d'identité vit dans authentik.

Cette page met les trois rôles en place sur une seule machine : authentik en conteneurs, le serveur MCP en Python local, un client en script. Le code complet est disponible dans le lab lab-ia-mcp/mcp/oauth.

authentik est un fournisseur d'identité open source. Le blog détaille son déploiement dans Installer authentik avec Docker Compose — trois conteneurs : PostgreSQL, le serveur, le worker.

Pour un lab reproductible, deux variables d'environnement changent la donne. AUTHENTIK_BOOTSTRAP_PASSWORD et AUTHENTIK_BOOTSTRAP_TOKEN créent au premier démarrage le compte akadmin et un jeton d'API connu — ce qui rend la configuration scriptable, sans passer par l'interface web.

compose.yml (extrait)
server:
# Image épinglée par tag ET digest : reproductibilité et supply chain.
image: ghcr.io/goauthentik/server:2026.2.1@sha256:46a71d75dfd3eec9bd0fb42e5e13a245394e1be2d0828eebfbb2662421e66a35
command: server
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:?}
AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:?}
ports:
- "9000:9000"

Démarrez la stack, puis attendez que la sonde de disponibilité réponde :

Fenêtre de terminal
docker compose up -d
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:9000/-/health/ready/

Un code 200 confirme qu'authentik a terminé ses migrations et qu'il est prêt à recevoir des appels d'API.

Un provider OAuth2 dans authentik, c'est la définition d'une application cliente : son client_id, son client_secret, ses scopes, sa clé de signature. Le script configure_authentik.py du lab le crée via l'API, de façon idempotente — on peut le rejouer sans rien casser.

Trois objets sont créés. Un scope mapping mcp:read : la portée applicative que le serveur MCP exigera. Le provider lui-même, en client confidentiel, signé en RS256 pour que les access tokens soient des JWT vérifiables. Une application qui rend le provider accessible.

configure_authentik.py (extrait)
provider = api.post("/providers/oauth2/", json={
"name": "mcp-blog-helper",
"authorization_flow": AUTH_FLOW,
"client_type": "confidential",
"signing_key": SIGNING_KEY, # certificat RS256
"access_token_validity": "minutes=10",
"property_mappings": mappings, # mcp:read + scopes OIDC
}).raise_for_status().json()

Le script crée enfin un compte de service et son jeton. Ce compte permettra au script de test d'obtenir des access tokens en mode machine-à-machine, sans navigateur. À l'issue, les identifiants — client_id, client_secret, URL du token endpoint et de la JWKS — sont écrits dans un fichier credentials.env que le serveur et le client liront.

Côté serveur MCP, l'authentification repose sur deux éléments du SDK : un TokenVerifier — l'objet qui sait dire si un jeton est valide — et un bloc AuthSettings — la déclaration de ce que le serveur exige.

Le TokenVerifier est un protocole avec une seule méthode, verify_token. Notre implémentation valide un JWT émis par authentik : récupération de la clé publique via la JWKS, puis contrôle de la signature, de l'émetteur (iss), de l'audience (aud) et de l'expiration (exp). Tout échec renvoie None, et le serveur répondra 401.

server.py (extrait)
import jwt
from mcp.server.auth.provider import AccessToken, TokenVerifier
class AuthentikTokenVerifier(TokenVerifier):
def __init__(self) -> None:
self._jwks = jwt.PyJWKClient(CONF["OAUTH_JWKS_URL"])
async def verify_token(self, token: str) -> AccessToken | None:
try:
key = self._jwks.get_signing_key_from_jwt(token)
claims = jwt.decode(
token, key, algorithms=["RS256"],
audience=CLIENT_ID, issuer=ISSUER,
)
except jwt.InvalidTokenError:
return None
return AccessToken(
token=token,
client_id=claims.get("azp", CLIENT_ID),
scopes=claims.get("scope", "").split(),
expires_at=claims.get("exp"),
)

Le serveur déclare ensuite ce verifier et sa politique d'accès. required_scopes=["mcp:read"] est décisif : il ne suffit pas qu'un jeton soit authentique, il doit aussi porter la bonne portée.

server.py (extrait)
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import FastMCP
mcp = FastMCP(
"blog-helper-secure",
host="127.0.0.1", port=8800,
token_verifier=AuthentikTokenVerifier(),
auth=AuthSettings(
issuer_url=ISSUER,
resource_server_url="http://localhost:8800",
required_scopes=["mcp:read"],
),
)

Avec cette configuration, le serveur lancé en transport="streamable-http" applique l'authentification à chaque requête. Une requête sans jeton ne reçoit pas un mur muet, mais une réponse conforme à OAuth 2.1 :

Fenêtre de terminal
curl -s -D - -o /dev/null -X POST http://localhost:8800/mcp -d '{}'
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token",
error_description="Authentication required",
resource_metadata="http://localhost:8800/.well-known/oauth-protected-resource"

L'en-tête WWW-Authenticate pointe vers un document de métadonnées que le serveur publie automatiquement. Ce document indique au client quel est le serveur d'autorisation à contacter et quelles portées sont attendues — c'est le mécanisme par lequel un client comme Claude Desktop découvre comment s'authentifier.

Reste le troisième rôle : le client. Pour obtenir un access token, OAuth 2.1 offre plusieurs grants. Deux comptent ici, et il faut les distinguer.

Le grant authorization_code avec PKCE est le flux interactif. C'est celui qu'utilise Claude Desktop : à la première connexion à un serveur MCP protégé, l'application ouvre le navigateur, l'utilisateur s'authentifie sur authentik, et le client récupère un jeton. C'est le flux recommandé pour tout client piloté par un humain.

Le grant client_credentials est le flux machine-à-machine : pas de navigateur, pas d'utilisateur. Un script, un job de CI ou un agent non interactif présente un client_id, un client_secret et les identifiants d'un compte de service, et reçoit un jeton. C'est ce flux que le lab utilise pour ses tests, parce qu'il est entièrement scriptable.

get_token.py (extrait)
response = httpx.post(conf["OAUTH_TOKEN_URL"], data={
"grant_type": "client_credentials",
"client_id": conf["OAUTH_CLIENT_ID"],
"client_secret": conf["OAUTH_CLIENT_SECRET"],
"username": conf["SA_USERNAME"],
"password": conf["SA_TOKEN"],
"scope": "openid mcp:read",
})
token = response.json()["access_token"]

Le jeton obtenu accompagne ensuite chaque requête MCP. Avec le client Streamable HTTP du SDK, on passe un httpx.AsyncClient pré-configuré avec l'en-tête d'autorisation :

auth = {"Authorization": f"Bearer {token}"}
async with httpx.AsyncClient(headers=auth) as http:
async with streamable_http_client(url, http_client=http) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()

La sécurité ne se constate pas, elle se teste. Le lab automatise les quatre comportements qui définissent un Resource Server correct, avec pytest et le grant client_credentials.

test_oauth.py::test_sans_jeton_renvoie_401 PASSED
test_oauth.py::test_jeton_invalide_renvoie_401 PASSED
test_oauth.py::test_jeton_sans_le_scope_renvoie_403 PASSED
test_oauth.py::test_jeton_valide_liste_les_tools PASSED
test_oauth.py::test_jeton_valide_appelle_un_tool PASSED

Chaque test verrouille une garantie. Sans jeton : 401. Jeton invalide — une chaîne qui n'est pas un JWT signé : 401. Jeton authentique mais sans le scope mcp:read : 403 — la distinction est essentielle, car elle prouve que le serveur vérifie la portée, pas seulement l'identité. Jeton valide et complet : l'accès aux tools fonctionne. Ce dernier cas traverse toute la chaîne — authentik émet, le serveur vérifie via la JWKS, MCP répond.

La méthode rejoint celle du guide Tester et déboguer un serveur MCP : on ne se fie pas à un essai manuel, on fige le comportement attendu dans une suite rejouable à chaque modification.

SymptômeCause probableSolution
401 avec un jeton pourtant obtenuaud ou iss du JWT ne correspond pasVérifier audience= et issuer= dans jwt.decode
403 sur un jeton valideLe scope mcp:read n'est pas dans le jetonDemander scope=mcp:read et l'inclure dans les property mappings du provider
InvalidSignatureErrorMauvaise JWKS ou clé de signature changéeVérifier OAUTH_JWKS_URL ; la clé de signature du provider doit être un certificat RS256
Le client se bloque sans réponseRequête sur /mcp/ (avec slash final)Appeler /mcp sans slash final — sinon redirection 307
ExpiredSignatureErrorAccess token expiré (10 min)Réobtenir un jeton ; en interactif, laisser le refresh token opérer
authentik renvoie invalid_clientclient_id ou client_secret erronéRégénérer credentials.env avec configure_authentik.py
  • Un serveur MCP stdio est isolé ; un serveur MCP HTTP exige une authentification — il expose des actions.
  • La spécification d'autorisation MCP réutilise OAuth 2.1 : le serveur MCP est un Resource Server, jamais un serveur d'autorisation.
  • Trois rôles séparés : authentik authentifie et émet, le serveur MCP vérifie, le client présente le jeton.
  • Le serveur valide un JWT via la JWKS de l'émetteur : signature, iss, aud, exp — aucun appel à authentik par requête.
  • required_scopes impose une portée : un jeton authentique sans le bon scope reçoit 403, pas 200.
  • client_credentials est le grant machine (scriptable, testable) ; authorization_code + PKCE est le grant interactif de Claude Desktop.
  • Une requête non authentifiée reçoit 401 + un en-tête WWW-Authenticate qui permet au client de découvrir le serveur d'autorisation.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn