Une frontière OAuth double pour les passerelles MCP : protéger l’identité de l’utilisateur
Les passerelles MCP ne devraient pas regrouper toutes les appels dans un seul compte de service. La validation entrante, associée à un échange OAuth effectué en leur nom, permet de préserver l’identité de chaque utilisateur.
Sources: Solving the Identity Termination Problem in MCP Gateway Architectures
Un passage qui se situe entre un agent géré par MCP et les systèmes d’entreprise doit disposer de deux limites OAuth, afin que la personne ayant soumis la demande reste identifiable dans chaque système que l’agent touche.
L’échec
Examinons un échec typique. Un employé demande à un agent, via un passage MCP, de soumettre une demande pour un collègue. Le passage vérifie l’identité de l’employé auprès du fournisseur d’identité de l’entreprise, valide le jeton porteur, puis transmet la demande à l’API ITSM qui crée la demande. Un mois plus tard, un auditeur consulte le stockage des demandes et constate qu’un seul nom apparaît sur toutes les demandes soumises par l’agent : l’identité de l’application du passage lui-même. Les véritables demandeurs ne figurent pas dans les archives.
La même faille se reproduit dans tous les systèmes en aval situés derrière le gateway. Le système de gestion des ressources humaines attribue chaque demande de congé au compte de service du gateway, le registre des actifs attribue chaque transfert de dispositif à ce même compte, et le flux de travail d’achat le liste comme autorisateur pour chaque demande. L’identité s’arrête au gateway, et rien par la suite ne peut la récupérer.
Les gateways MCP sont devenus le pont par défaut entre les agents LLM et les systèmes d’entreprise, mais la plupart des déploiements en production laissent une question en suspens : quelle identité autorise chaque appel en aval ? Deux anti-modèles dominent cette situation.
Identité terminée. Après avoir vérifié qui appelle, le gateway communique avec chaque système backend en tant qu’entité de service partagée. Tout ce qu’il fait au système informatique, au système de ressources humaines ou aux processus d’achat est attribuable à son compte de service et non à la personne derrière la demande. Les traces d’audit deviennent inutiles et le principe du moindre privilège est compromis. Une instruction malveillante peut permettre au gateway d’exercer pleinement ses fonctions sur n’importe quel système backend qu’il peut atteindre.
Faire confiance au client. Le gateway accepte tout token de portage qui lui parvient et le transmet tel quel aux API en aval, sans vérifier l’émetteur, le public cible ou la portée du token. Le vol d’un token à n’importe quel moment du parcours peut alors autoriser silencieusement des appels arbitraires vers les systèmes en aval.
Aucune de ces solutions n’est acceptable pour l’IA agente d’entreprise, en particulier dans le cadre de la fonction de gouvernance du NIST AI Risk Management Framework, qui exige que la responsabilité individuelle soit préservée dans toutes les actions automatisées sur des systèmes réglementés.
La proposition
La conception comporte deux limites OAuth. Au niveau du client, la limite d’entrée extrait et vérifie un jeton d’accès délégué, c’est-à-dire un jeton porteur OAuth 2.0 (RFC 6749) émis à l’utilisateur appelant pour le public cible du passerelle. La limite de sortie effectue un échange au nom de l’échangeur (conformément aux principes de l’échange de jetons RFC 8693) ou, lorsque la chaîne de confiance le permet, un transfert délégué direct, et génère un jeton destiné au public en aval qui conserve toujours l’identité de l’utilisateur initial.
L’autorisation de l’outil est déterminée à la frontière, à partir des informations contenues dans les jetons analysés, et non intégrée directement dans les implémentations de l’outil. Cela fait que c’est le gateway qui gère la transmission de l’identité, plutôt que chaque outil ou backend dû en garder mémoire.
Le modèle : OAuth à frontière déléguée
En bref : le gateway utilise deux frontières OAuth au lieu d’une seule, et l’identité de l’utilisateur constitue le contrat qui circule entre elles.
La frontière d’entrée. L’extraction du jeton porteur, la validation de l’émetteur et du public ainsi que l’analyse des prétentions ont toutes lieu au sein d’une même couche du gateway, généralement un middleware de requête ou un injecteur de dépendances de type FastMCP, qui s’exécute avant chaque fonctionnalité de l’outil. C’est ici que l’on répond à la question « Qui appelle, et a-t-il le droit d’utiliser cet outil ? », et aucune implémentation d’outil ne peut l’ignorer. La correspondance entre les scopes et les outils est déclarative : chaque outil exposé indique les scopes OAuth dont il a besoin, et la frontière refuse de l’exécuter si le jeton ne les contient pas. Ce refus constitue une erreur d’autorisation structurée, et non un code 5xx résultant d’une vérification des permissions effectuée en profondeur dans la pile qui aurait consommé des ressources inutilement.
La frontière de sortie. L’appel en aval est authentifié par un jeton indiquant l’utilisateur d’origine, et jamais par l’identité de service du gateway lui-même. Deux mécanismes permettent d’y parvenir. Un échange agissant au nom de l’utilisateur (Microsoft Entra OBO, un pool d’identités AWS Cognito ou un échange STS personnalisé) émet un jeton nouveau destiné à l’API en aval, l’appelant humain restant son sujet. Le redirigement direct, réservé aux services en aval couverts par un accord de confiance explicite avec le gateway, transmet le jeton entrant tel quel. C’est cet échange qui rend la propagation vérifiable et non simplement espérée.
Entre les deux. Les jetons sont considérés comme des états valables uniquement pour une requête. Rien n’est mémorisé entre les requêtes, rien n’est conservé dans une session, et aucun état modifiable n’est partagé. Chaque appel à une fonction outil MCP lit à nouveau le jeton depuis les en-têtes en temps réel et l’oublie une fois la requête terminée.
Implémentation
Reçue : extraction du jeton délégué
La frontière de réception correspond à un middleware léger qui s’exécute avant chaque fonction outil. Dans un serveur MCP en Python basé sur un framework de type FastMCP, on s’appuie sur une injection de CurrentHeaders pour récupérer le jeton porteur à l’aide d’une petite utilité :
from typing import Optional
def extract_bearer_token(headers: dict) -> str:
auth = headers.get("authorization") or headers.get("Authorization")
if not auth or not auth.startswith("Bearer "):
raise AuthError("missing or malformed bearer token")
return auth[len("Bearer "):]
Ensuite vient la validation de ce jeton par rapport à l’émetteur et au public attendus par le gateway, à l’aide des clés de signature publiées par le fournisseur d’identité (JWKS) :
from jose import jwt
def validate_inbound_token(token: str, jwks: dict, audience: str, issuer: str) -> dict:
claims = jwt.decode(
token, jwks,
audience=audience,
issuer=issuer,
options={"require_exp": True, "require_iat": True},
)
return claims # sub, scp/scope, exp, etc.
La validation doit avoir lieu en interne. Externaliser la validation des JWT à un proxy intermédiaire peut fonctionner dans des topologies de confiance restreintes, mais cela prive le gateway de la capacité d’imposer une correspondance entre le champ de portée et l’outil avant que les effets secondaires de cet outil ne s’exécutent.
Sortante : au nom de l’échange
La frontière sortante échange le jeton d’utilisateur entrant contre un jeton destiné au public intermédiaire. Cet échange est ce qui distingue ce modèle d’un simple redirigeant, car l’API intermédiaire ne voit jamais directement le jeton entrant. Le gateway appelle l’endpoint de jeton du fournisseur d’identité avec une requête urn:ietf:params:oauth:grant-type:jwt-bearer (Entra) ou une requête assume-role-with-web-identity (AWS), en transmettant le jeton entrant comme assertion de sujet. Le fournisseur renvoie alors un nouveau jeton pour l’API intermédiaire, dont la clé sub reste celle de l’utilisateur initial.
def exchange_obo(inbound_token: str, downstream_audience: str) -> str:
response = requests.post(
f"{IDP_TOKEN_ENDPOINT}",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"client_id": GATEWAY_CLIENT_ID,
"client_secret": GATEWAY_CLIENT_SECRET,
"assertion": inbound_token,
"scope": f"{downstream_audience}/.default",
"requested_token_use": "on_behalf_of",
},
timeout=5,
)
response.raise_for_status()
return response.json()["access_token"]
Le jeton échangé est ensuite attaché à l’appel HTTP sortant :
def call_downstream(downstream_token: str, path: str) -> dict:
return requests.get(
f"{DOWNSTREAM_BASE_URL}{path}",
headers={"Authorization": f"Bearer {downstream_token}"},
timeout=10,
).json()
Mappage des scopes et propagation de l’identité
Les fonctions d’outil déclarent les scopes requis lors de leur enregistrement, et la limite les applique avant que le corps de l’outil ne s’exécute :
@tool(required_scopes=["ticketing.write"])
def open_ticket(headers: CurrentHeaders, summary: str, body: str) -> dict:
token = extract_bearer_token(headers)
claims = validate_inbound_token(token, jwks, audience=GATEWAY_AUD, issuer=ISSUER)
enforce_scopes(claims, required=["ticketing.write"]) # central, not tool-local
downstream_token = exchange_obo(token, downstream_audience=TICKETING_AUD)
return call_downstream(downstream_token, path="/tickets")
Comme la mise en correspondance des scopes est déclarative, elle peut être inspectée. Quiconque audite le gateway dispose de tous les outils ainsi que des scopes requis directement depuis le registre, sans avoir besoin de lire le code de l’outil.
La frontière d’entrée est identique chez tous les fournisseurs de services cloud. Seules les connexions de sortie diffèrent, et les deux implémentations présentées ci-dessous partagent une structure globale similaire.
Sur AWS : API Gateway, Lambda et Cognito
Au sein d’AWS, la limite d’entrée peut être un autoriseur Lambda personnalisé sur Amazon API Gateway. Celui-ci vérifie le jeton par rapport à un pool d’utilisateurs Amazon Cognito et renvoie un document de politique ainsi qu’une carte de contexte des prétentions analysées, permettant ainsi au Lambda du serveur MCP de récupérer ces prétentions depuis event.requestContext.authorizer sans avoir à les vérifier à nouveau. Pour la sortie, sts:AssumeRoleWithWebIdentity prend le jeton d’entrée (ou un jeton émis par un pool d’identités Cognito) et génère des identifiants AWS temporaires liés à l’identité fédérée de l’utilisateur. Tout point de terminaison API Gateway qui se trouve derrière, ou toute API externe qui fait confiance aux JWT Cognito, peut alors identifier l’utilisateur grâce à la prétention sub.
Un échange minimal entre Cognito et STS dans le Lambda du gateway se présente comme suit :
import boto3
def aws_obo(inbound_token: str, role_arn: str) -> dict:
sts = boto3.client("sts")
creds = sts.assume_role_with_web_identity(
RoleArn=role_arn,
RoleSessionName="mcp-gateway-obo",
WebIdentityToken=inbound_token,
DurationSeconds=900,
)
return creds["Credentials"]
La politique de confiance de le rôle supposé désigne le pool d’utilisateurs Cognito comme principal fédéré et vérifie également l’attribut aud du jeton entrant, ce qui signifie qu’uniquement un jeton émis pour ce gateway peut assumer ce rôle.
Sur Azure : le flux Entra on-behalf-of
Sur Azure, la limite d’entrée compare le jeton reçu avec l’enregistrement d’application Entra du gateway, en utilisant l’URI de l’ID d’application du gateway comme public cible. Pour l’échange sortant, elle exécute un échange Entra on-behalf-of destiné à l’enregistrement d’application propre de l’API en aval :
import msal
def entra_obo(inbound_token: str, downstream_scope: str) -> str:
app = msal.ConfidentialClientApplication(
client_id=GATEWAY_APP_ID,
client_credential=GATEWAY_CLIENT_SECRET,
authority=f"https://login.microsoftonline.com/{TENANT_ID}",
)
result = app.acquire_token_on_behalf_of(
user_assertion=inbound_token,
scopes=[downstream_scope],
)
if "access_token" not in result:
raise AuthError(result.get("error_description", "obo exchange failed"))
return result["access_token"]
L’API cible doit accepter des jetons dont le public est leur propre enregistrement d’application Entra. Sa logique d’autorisation lit la valeur oid (identifiant de l’objet) ou upn (nom principal de l’utilisateur) pour attribuer l’appel à l’utilisateur initial. La structure du schéma reste identique, seuls les connexions avec le fournisseur d’identité changent.
Le lien avec le journalisation et l’évaluation
L’identité propagée est particulièrement utile lorsqu’elle est enregistrée. Si le gateway enregistre l’utilisateur entrant à chaque invocation d’outil, un audit peut relier une action d’agent à une personne physique. Ce même champ peut être intégré dans les enregistrements d’évaluation, permettant de relier les exécutions de tests aux individus dont le comportement est pris en compte par le système. Considérez l’identité extraite par ce schéma comme la première colonne de chaque enregistrement d’audit et d’évaluation généré par le système.
Quand ce schéma convient et quand il ne convient pas
Il convient lorsque :
- Le gateway s’interface avec des systèmes où l’identité individuelle est cruciale pour l’autorisation, l’audit ou la conformité, tels que la gestion des services informatiques, les ressources humaines, la finance et les workflows réglementés.
- Le fournisseur d’identité d’entreprise prend en charge un standard d’échange : Entra OBO, Cognito avec l’identité web STS, ou l’échange de tokens Auth0 et Okta.
- Les API en aval se trouvent dans la même zone de confiance que le gateway et peuvent être configurées pour accepter des tokens d’audience utilisateur.
- Le déploiement relève des normes NIST AI RMF, SOX, HIPAA ou de réglementations similaires qui exigent une responsabilisation au niveau de l’utilisateur pour les actions automatisées.
Ce cas n’est pas applicable lorsque :
- Chaque appel en aval n’est manifestement pas spécifique à un utilisateur, par exemple des données de référence en lecture seule sans effets secondaires attribuables à un utilisateur.
- Un proxy intermédiaire fiable applique déjà une sémantique déléguée complète pour le gateway, rendant le travail du gateway redondant. Dans ce cas, le gateway ne crée que la frontière d’entrée ainsi qu’une variante déléguée de confiance pour la partie de sortie, en transférant les données directement dans le cadre d’un contrat de confiance explicite et en acceptant que le proxy constitue la véritable frontière de sécurité.
- Une API intermédiaire refuse les tokens d’audience utilisateur et il n’est pas envisageable de la modifier. Reportez ce schéma jusqu’à ce que cela change. Recourir à un service principal « pour l’instant » doit être une décision consciente avec les risques clairement consignés, et non une solution par défaut.
Considérations opérationnelles :
- Latence. Chaque échange OBO entraîne un déplacement supplémentaire vers l’endpoint des tokens pour chaque appel sortant. Conservez les tokens échangés tant que le token d’entrée reste valide, en utilisant comme clé
(sub, downstream_audience), et ne les partagez jamais entre utilisateurs. - Régulation par le fournisseur d’identité. Les quotas liés aux endpoints des tokens ne sont pas simples à gérer. Prévoyez une marge de manœuvre et suivez les taux d’échec des échanges.
- Renouvellement. Les sessions des agents durent plus longtemps que la durée de vie d’un seul token. Prévoyez un mécanisme de rotation des tokens de renouvellement, ou acceptez que la boucle d’un agent s’arrête lorsque son token expire, ce qui est souvent la meilleure approche en termes de sécurité.
- Recours à un compte de service. Prévoyez un chemin d’échange via un compte de service pour les tâches qui ne nécessitent pas d’utilisateur, comme les vérifications de santé ou les synchronisations en arrière-plan planifiées. Assurez-vous que ce mécanisme soit explicite, à portée restreinte et suivi par des journaux.
Où ce modèle présente des limites
Il part du principe que le fournisseur d’identité prend en charge un échange standard, or de nombreux fournisseurs plus anciens ne le font pas. Dans de tels environnements, la solution de repli consiste à transmettre directement les tokens, sous réserve d’un accord écrit de confiance avec chaque service intermédiaire. Cela est acceptable pour les services backend situés dans la zone de confiance du gateway, mais insuffisant lorsque c’est une autre équipe ou un fournisseur externe qui gère le service intermédiaire. La bonne approche de migration consiste à mettre à niveau le fournisseur d’identité, plutôt que de se contenter définitivement du transfert direct.
Cela suppose également que les services en aval acceptent des jetons d’audience utilisateur. De nombreux systèmes d’entreprise datent d’avant l’authentification déléguée et ne s’authentifient qu’en fonction des principaux de service. Dans ce cas, ce modèle doit attendre que le service soit mis à jour ou qu’un proxy conscient des identités soit introduit devant lui. Le faire partiellement, avec certaines appels attribués à l’utilisateur et d’autres au service, constitue l’état normal pendant la migration, mais cela doit être considéré comme une dette technique avec une date de résolution et non normalisé.
Enfin, cela ne résout pas la question de la responsabilité de l’agent en tant qu’acteur. Préserver l’identité de l’être humain au cours des transitions entre les passerelles constitue le contrôle principal approprié, mais un agent basé sur un LLM peut néanmoins être incité à entreprendre des actions que l’utilisateur n’a jamais demandées littéralement. Ce modèle garantit l’attribution des actions, mais pas l’intention derrière elles. Les contrôles compensatoires assument cette responsabilité : confirmation humaine pour les actions à fort impact, portées minimales aux frontières d’entrée ainsi que enregistrement des preuves pour chaque appel.
Checklist d’adoption
- Documenter le contrat d’entrée (émetteur, public cible, portées prises en charge) sous forme d’interface versionnée et la gérer comme une API publique.
- Mettre en œuvre la correspondance entre portées et outils via un champ déclaratif lors de l’enregistrement des outils, et non par des vérifications ad hoc dispersées dans leur code.
- Mettre en place un échange au nom de l’entreprise avec le fournisseur d’identité d’entreprise pour chaque backend intermédiaire. Définir l’échange au nom de l’entreprise comme valeur par défaut et conserver l’envoi direct pour les backends situés dans la zone de confiance explicite du gateway.
- Cachez les jetons échangés pendant toute la durée de vie du jeton entrant, en les identifiant par l’identifiant de l’utilisateur et le public cible, et ne les partagez jamais entre utilisateurs.
- Lors de chaque appel à l’outil, enregistrez à la fois le
subentrant et lesubsortant, afin que la propagation puisse être auditée du début à la fin. - Permettez un chemin d compte de service à portée restreinte pour les opérations sans utilisateur, et exigez une approbation chaque fois qu’il est utilisé.
- Enregistrez les API intermédiaires qui rejettent les jetons destinés à un public d’utilisateurs en tant que dette architecturale, et travaillez à éliminer cette limitation.
Alors que les entreprises américaines et les programmes fédéraux adoptent le cadre de gestion des risques liés à l’IA de NIST, sa fonction de gouvernance exige que la responsabilité individuelle persiste malgré les actions automatisées sur les systèmes réglementés. Arrêter l’identification au niveau du point d’accès va à l’encontre de ce principe et peut entraîner des constats d’audit dans les environnements SOX, HIPAA et FedRAMP, où chaque modification automatisée doit être attribuée à une personne identifiée.
Pour en savoir plus
Les bases de ce cadre sont le RFC 6749 (le framework d’autorisation OAuth 2.0) et le RFC 8693 (l’échange de tokens OAuth 2.0) émis par l’IETF. La documentation des fournisseurs traite du flux On-Behalf-Of d’OAuth 2.0 de la plateforme d’identité Microsoft, de la référence API AWS STS AssumeRoleWithWebIdentity ainsi que des pools d’identité Amazon Cognito. NIST publie le cadre de gestion des risques liés à l’IA (AI RMF 1.0), et la spécification MCP comprend une section sur l’autorisation qui vaut la peine d’être lue en complément de tout ce qui précède.