Dans le volet 1, on a donné à chaque workload une identité forte : un agent, un outil, un service prouve qui il est par un SVID signé, sans secret. C’est le socle. Mais il laisse une question entière ouverte — et c’est la plus agentique de toutes.

Un agent n’agit presque jamais pour lui-même. Un utilisateur demande quelque chose à un agent superviseur, qui délègue à un agent chercheur, qui appelle un outil. Le SVID du chercheur prouve que ce process est bien le chercheur. Il ne dit rien de l’essentiel : que le chercheur agit pour le compte de cet utilisateur-là, via ce superviseur-là. Cette intention — la provenance — n’est inscrite nulle part. Elle se perd au premier saut.

Le problème : l’identité du process ne dit pas pour qui il agit

Dans un graphe multi-agents, la délégation est le mode par défaut, pas l’exception. Chaque arête du graphe est un « agis pour moi ». Or les modèles d’authentification classiques connaissent deux mondes — les humains et les workloads — et un agent n’est ni tout à fait l’un, ni tout à fait l’autre : c’est un workload qui porte l’intention d’un humain à travers plusieurs sauts.

Ce qui casse, concrètement, quand seule l’identité de process voyage :

  • Perte de provenance — chaque service ne voit que son appelant immédiat. L’outil voit le chercheur, jamais l’utilisateur d’origine ni le superviseur intermédiaire. Le contexte d’exécution s’évapore à chaque hop.
  • Trou d’audit — impossible de répondre à « quel utilisateur a déclenché cette suppression, et via quels agents ? ». La trace s’arrête au dernier maillon.
  • Politiques inapplicables — vos contrôles d’accès fins existants sont écrits sur l’identité de l’utilisateur. Si elle ne parvient pas jusqu’à l’outil, on ne peut plus les appliquer en aval.
  • Le député confus (confused deputy) — un agent doté de larges privilèges, manipulé par une injection, agit « pour » un utilisateur qui n’a jamais rien demandé. Sans chaîne de délégation vérifiable, rien ne distingue l’appel légitime de l’appel détourné.

Ce qu’on veut, c’est faire voyager l’intention : un objet qui dit, à chaque saut, « le principal est cet utilisateur ; il a délégué au superviseur, qui a délégué au chercheur ; et c’est le chercheur qui te parle maintenant » — le tout non falsifiable, et qui s’arrête à la porte du destinataire visé.

Délégation vs usurpation

Il y a deux façons, techniquement, de « parler au nom de quelqu’un ». La distinction est au cœur de RFC 8693 (OAuth 2.0 Token Exchange) et elle décide de tout.

  • Usurpation (impersonation) — le jeton transmis en aval ressemble en tout point à celui de l’utilisateur. Le destinataire ne voit que l’utilisateur : l’agent est invisible. C’est simple… et c’est exactement ce qu’on ne veut pas : aucune trace de l’agent, aucun audit possible, et un agent compromis se fait passer pour n’importe qui sans laisser d’empreinte.
  • Délégation (delegation) — le jeton enregistre les deux : le principal (l’utilisateur) et l’acteur (l’agent qui agit en ce moment), ainsi que les acteurs intermédiaires. Le destinataire sait « le chercheur, agissant pour le compte de l’utilisateur, via le superviseur ». La provenance est préservée, l’audit est complet.

Faire voyager la provenance : le jeton de délégation

Le véhicule de l’intention est un JWT dont la charge utile dit quatre choses :

{
  "iss": "https://sts.agentique.world",
  "sub": "alice@deep-5.com",          // le PRINCIPAL — ne change jamais
  "aud": "spiffe://agentique.world/tool/search",  // le pair visé, et lui seul
  "exp": 1750000180,                  // courte durée : quelques minutes
  "act": {                            // l'ACTEUR courant…
    "sub": "spiffe://agentique.world/agent/researcher",
    "act": {                          // …englobant la chaîne reçue (RFC 8693 §4.1)
      "sub": "spiffe://agentique.world/agent/supervisor"
    }
  }
}

Le sub est le principal : l’utilisateur d’origine, invariant de bout en bout. Le claim act porte l’acteur — et il est imbriqué : l’acteur le plus récent englobe le précédent. Lire act de l’extérieur vers l’intérieur, c’est remonter la chaîne de délégation : chercheur → superviseur → (utilisateur).

Fig.01 · La chaîne d'acteurs s'allonge à chaque saut
Utilisateur humain · OIDC Superviseur /agent/supervisor Chercheur /agent/researcher Outil /tool/search jeton · hop 1 act: [user] jeton · hop 2 act: [user, superv.] jeton · hop 3 act: [user, superv., cherch.] Chaque saut ré-émet un jeton à courte durée de vie, destiné au pair suivant, et y empile le maillon de délégation : la provenance complète voyage jusqu'à l'outil.
Le principal (l'utilisateur) reste constant ; à chaque hop, le jeton est ré-émis pour le pair suivant et y empile le maillon de délégation. La provenance complète arrive jusqu'à l'outil.

Côté code, forger et relire un tel jeton tient en quelques lignes avec py-jwt :

import jwt, time

def jeton_delegue(principal, acteur, act_parent, audience, cle_privee, kid, ttl=180):
    """Forge un JWT de délégation.
    - principal : l'utilisateur d'origine (sub), inchangé sur toute la chaîne.
    - acteur    : le workload qui agit MAINTENANT (son SPIFFE ID).
    - act_parent: le claim act reçu en entrée — l'acteur courant l'englobe.
    """
    maintenant = int(time.time())
    payload = {
        "iss": "https://sts.agentique.world",
        "sub": principal,
        "aud": audience,                 # le pair visé, et lui seul
        "iat": maintenant,
        "exp": maintenant + ttl,         # courte durée de vie
        "act": {"sub": acteur, **({"act": act_parent} if act_parent else {})},
    }
    return jwt.encode(payload, cle_privee, algorithm="RS256", headers={"kid": kid})


def chaine_acteurs(payload) -> list[str]:
    """Déplie le claim act : l'acteur le plus récent d'abord."""
    chaine, act = [], payload.get("act")
    while act:
        chaine.append(act["sub"])
        act = act.get("act")
    return chaine   # ['…/agent/researcher', '…/agent/supervisor']

Le token exchange : ré-émettre à chaque saut

Pourquoi ne pas simplement transférer le même jeton de bout en bout ? Parce qu’un bon jeton de délégation est lié à un seul destinataire (aud) — comme le JWT-SVID du volet 1. Transféré, il serait soit rejouable (audience trop large), soit refusé (audience trop étroite), et il n’enregistrerait jamais les maillons intermédiaires. La bonne primitive, c’est le token exchange de RFC 8693 : à chaque saut, l’agent appelant échange le jeton qu’il a reçu contre un jeton neuf, destiné au pair suivant et enrichi de son propre maillon.

L’échange est opéré par un STS (Security Token Service) : un courtier de confiance unique. Et c’est là que les deux volets se rejoignent — l’appelant s’authentifie auprès du STS avec son JWT-SVID (volet 1). Le STS ne ré-émet un jeton que pour un workload attesté et autorisé à déléguer.

Fig.02 · Token exchange ancré sur le SVID
SVID + jeton entrant jeton sortant Agent appelant subject_token + SVID STS token exchange RFC 8693 vérifie le registre Jeton (hop N+1) aud = pair suivant act += appelant Un courtier de confiance unique : il n'émet un jeton que si l'appelant est attesté (SVID) et autorisé à déléguer. Chaque jeton est court, mono-saut et tracé — point d'audit central.
L'agent présente le jeton entrant (subject_token) ET son SVID (actor_token). Le STS vérifie l'attestation et le droit de délégation, puis ré-émet un jeton court, mono-saut, à la chaîne d'acteurs enrichie.

Côté appelant, l’échange est une requête POST standard (RFC 8693) :

import httpx

def echanger(jeton_entrant: str, svid_appelant: str, audience_visee: str) -> str:
    """Échange le jeton reçu contre un jeton pour le saut suivant.
    - subject_token : ce qu'on a reçu — porte le principal et la chaîne en cours.
    - actor_token   : notre JWT-SVID (volet 1) — prouve QUEL workload demande.
    """
    r = httpx.post("https://sts.agentique.world/token", data={
        "grant_type":         "urn:ietf:params:oauth:grant-type:token-exchange",
        "subject_token":      jeton_entrant,
        "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
        "actor_token":        svid_appelant,
        "actor_token_type":   "urn:ietf:params:oauth:token-type:jwt",
        "audience":           audience_visee,
    })
    r.raise_for_status()
    return r.json()["access_token"]

Et côté STS, le cœur de l’échange — validation, droit de délégation, ré-émission :

# côté STS — le courtier de confiance
MON_ISS = "https://sts.agentique.world"

# Registre de délégation : qui (workload attesté) peut déléguer vers quelle cible.
REGISTRE_DELEGATION = {
    "spiffe://agentique.world/agent/supervisor": {
        "spiffe://agentique.world/agent/researcher",
    },
    "spiffe://agentique.world/agent/researcher": {
        "spiffe://agentique.world/tool/search",
    },
}

def echange(subject_token: str, actor_token: str, audience: str) -> str:
    # 1. L'appelant est-il un workload attesté ? (SVID validé via la Workload API, volet 1)
    appelant = valider_svid(actor_token)            # -> spiffe://…/agent/supervisor
    # 2. Le jeton entrant est-il valide et bien adressé à cet appelant ?
    porteur = valider_jwt(subject_token, audience=appelant)
    # 3. L'appelant a-t-il le droit de déléguer vers cette cible ? (may_act)
    if audience not in REGISTRE_DELEGATION.get(appelant, set()):
        raise PermissionError(f"{appelant} non autorisé à déléguer vers {audience}")
    # 4. Ré-émettre : principal inchangé, acteur empilé, audience = saut suivant.
    return jeton_delegue(
        principal=porteur["sub"],
        acteur=appelant,
        act_parent=porteur.get("act"),
        audience=audience,
        cle_privee=CLE_STS, kid=KID_STS,
    )

Hands-on : déléguer dans un graphe LangGraph

Reprenons notre graphe : un superviseur reçoit le jeton de l’utilisateur, délègue à un chercheur, qui appelle l’outil. Chaque nœud ré-émet le jeton avant de passer la main. Le jeton de délégation courant voyage dans l’état du graphe.

D’abord, le validateur de JWT — il vérifie signature (JWKS du STS), expiration et audience, comme on validait un JWT-SVID au volet 1 :

import jwt
from jwt import PyJWKClient

JWKS = PyJWKClient("https://sts.agentique.world/.well-known/jwks.json")

def valider_jwt(token: str, audience: str) -> dict:
    """Signature (JWKS du STS) + expiration + audience. Lève si invalide."""
    cle = JWKS.get_signing_key_from_jwt(token).key
    return jwt.decode(
        token, cle, algorithms=["RS256"], audience=audience,
        options={"require": ["exp", "aud", "sub"]},
    )

Câbler la délégation dans les nœuds

Chaque nœud s’authentifie auprès du STS avec son JWT-SVID (svid_jwt(...), du volet 1), échange le jeton reçu, puis passe la main. Le sub (l’utilisateur) traverse tout le graphe sans qu’aucun nœud n’ait à le connaître.

from typing import TypedDict
from langgraph.graph import StateGraph, END

STS = "https://sts.agentique.world"

class Etat(TypedDict):
    requete: str
    jeton: str        # jeton de délégation courant, ré-émis à chaque nœud

def superviseur(etat: Etat) -> Etat:
    # Reçu : le jeton de l'utilisateur (aud = …/agent/supervisor). Délègue au chercheur.
    svid = svid_jwt(audience=STS)                       # JWT-SVID, volet 1
    jeton = echanger(etat["jeton"], svid,
                     audience="spiffe://agentique.world/agent/researcher")
    return {**etat, "jeton": jeton}

def chercheur(etat: Etat) -> Etat:
    # Valide ce qu'on reçoit, puis délègue à l'outil.
    valider_jwt(etat["jeton"], audience="spiffe://agentique.world/agent/researcher")
    svid = svid_jwt(audience=STS)
    jeton = echanger(etat["jeton"], svid,
                     audience="spiffe://agentique.world/tool/search")
    httpx.post("https://outil.interne/recherche",
               headers={"Authorization": f"Bearer {jeton}"},
               json={"q": etat["requete"]})
    return etat

g = StateGraph(Etat)
g.add_node("superviseur", superviseur)
g.add_node("chercheur", chercheur)
g.add_edge("superviseur", "chercheur")
g.add_edge("chercheur", END)
g.set_entry_point("superviseur")
agent = g.compile()

Vérifier la chaîne côté outil

À l’arrivée, l’outil ne se contente pas de regarder son appelant direct : il relit toute la chaîne, et expose le principal à la couche de politique.

APPELANT_DIRECT_OK = {"spiffe://agentique.world/agent/researcher"}

def recevoir(token: str):
    p = valider_jwt(token, audience="spiffe://agentique.world/tool/search")
    chaine = chaine_acteurs(p)                 # acteur le plus récent d'abord
    if not chaine or chaine[0] not in APPELANT_DIRECT_OK:
        raise PermissionError(f"Appelant direct non autorisé : {chaine[:1]}")
    principal = p["sub"]                        # l'utilisateur d'origine
    # (principal, chaine, action) part vers la politique d'accès — cf. volet 3.
    return principal, chaine

Vérifier la chaîne, pas seulement le dernier maillon

C’est le piège central de la délégation, et le retour du député confus. Si l’outil ne valide que son appelant immédiat (« c’est bien le chercheur, j’exécute »), un chercheur compromis peut prétendre agir pour n’importe quel sub. La sécurité ne tient que si chaque maillon est vérifié :

  • Signature & fraîcheur — le jeton est signé par le STS (JWKS de confiance), non expiré, et son aud est bien moi. Acquis par valider_jwt.
  • Appelant direct attendu — le premier maillon de la chaîne est un pair autorisé à m’appeler (APPELANT_DIRECT_OK).
  • Délégation autorisée à chaque étage — c’est le rôle du registre may_act côté STS : le superviseur a le droit de déléguer au chercheur, le chercheur à l’outil. Une chaîne dont un maillon n’avait pas ce droit n’aurait jamais pu être émise.
  • Principal exposé à la politique — le sub n’est pas un argument que le modèle invente : c’est l’utilisateur prouvé, sur lequel l’aval applique ses règles.

Authentification, délégation… et la suite

On a maintenant deux faits prouvés cryptographiquement : qui est chaque workload (volet 1) et pour le compte de qui, via quelle chaîne, il agit (ce volet). Il manque encore une décision : étant donné (principal, chaîne d'acteurs, cible, action), est-ce autorisé ? C’est l’autorisation — la politique — et c’est un sujet à part entière.

volet 1 · SVID Identité qui es-tu
volet 2 · act Délégation pour qui, via qui
volet 3 Politique as-tu le droit
autorisé Action outil exécuté

La délégation transporte l’intention de façon fiable ; elle ne décide pas du droit. Un jeton qui prouve « le chercheur, pour Alice, via le superviseur » ne dit pas si Alice a le droit de lancer cette recherche-là sur cet index-là. Cette décision — fine, contextuelle, centralisée — est l’objet du volet 3 — Autorisation & politique.

Standards : où va l’industrie

Le sujet se normalise vite, porté par les déploiements d’agents à grande échelle :

  • RFC 8693 — OAuth 2.0 Token Exchange : le mécanisme d’échange et les claims act (délégation) / may_act (autorisation de déléguer) utilisés ici. C’est la fondation, déjà un standard.
  • Transaction Tokens (draft IETF OAuth, draft-ietf-oauth-transaction-tokens) : un jeton court, immuable, qui transporte le contexte de l’utilisateur d’origine à travers une chaîne d’appels internes — précisément le besoin agentique d’une provenance qui ne se perd pas entre microservices.
  • WIMSE (Workload Identity in Multi-System Environments, groupe de travail IETF) : standardise l’identité de workload (la lignée des SVID du volet 1) et la façon dont elle s’articule avec ces jetons d’appel. C’est le pont explicite entre nos deux volets.

Converger vers ces standards, c’est éviter une chaîne de délégation maison, fragile et inauditable — au profit de primitives interopérables et éprouvées.

Bonnes pratiques & pièges

Bonnes pratiques
  • Déléguer, jamais usurper : le jeton enregistre le principal (sub) ET la chaîne d'acteurs (act).
  • Ré-émettre à chaque saut (token exchange) : jeton mono-saut, audience précise, durée de quelques minutes.
  • Ancrer l'échange sur l'identité de workload (SVID, volet 1) : le STS ne mint que pour un appelant attesté.
  • Centraliser l'échange dans un STS : un seul point d'audit et de révocation pour toute la flotte.
  • Autoriser explicitement chaque maillon (may_act) : qui a le droit de déléguer pour le compte de qui.
  • Côté destinataire, vérifier la chaîne complète — appelant direct, droits de délégation, principal — pas le seul dernier maillon.
Pièges à éviter
  • Transférer le même jeton de bout en bout : rejouable, audience trop large, provenance perdue.
  • Usurper sans trace (le destinataire ne voit que l'utilisateur) : audit impossible, agent compromis indétectable.
  • Faire confiance au seul appelant immédiat : retour du député confus, n'importe quel principal injectable.
  • Jeton de délégation à longue durée de vie : une fuite ouvre toute la chaîne jusqu'à expiration.
  • Oublier de valider l'audience (comme au volet 1) : jeton rejouable ailleurs.
  • Confondre délégation et autorisation : la chaîne prouve l'intention, jamais le droit (volet 3).

Ce qu’il faut retenir

  • L’identité de workload (volet 1) prouve qui est un process ; elle ne dit pas pour le compte de qui il agit. Dans une chaîne d’agents, cette provenance doit voyager explicitement.
  • On délègue (le jeton garde principal et acteurs), on n’usurpe pas (qui efface l’agent de la trace). Le claim act porte la chaîne d’acteurs.
  • Le token exchange (RFC 8693) ré-émet le jeton à chaque saut : audience mono-saut, chaîne enrichie, durée courte — opéré par un STS ancré sur le SVID du volet 1.
  • Le destinataire valide la chaîne entière (appelant direct, droits de délégation may_act, principal), pas seulement son appelant immédiat — sinon le député confus revient.
  • La délégation transporte l’intention ; elle ne décide pas du droit. L’autorisation — la politique sur (principal, chaîne, action) — est le volet 3 de la série.

Avec l’identité (volet 1) et la délégation (ce volet), les pairs d’un système agentique savent qui se parle et pour le compte de qui. Reste à décider ce que chacun a le droit de faire : c’est là que se construit le contrôle d’accès fin et la politique centralisée — le prochain volet.

#securite#identite#delegation#on-behalf-of#oauth#token-exchange#zero-trust