OAuth Token Exchange, Identity Delegation et AI Agents : pourquoi AWS AgentCore et Azure Foundry ne…
Article technique pour développeurs et architectes — stack Spring Boot / Spring Cloud Gateway, comparant AWS AgentCore, Azure Foundry Agent…
[PARTIE-4]
OAuth Token Exchange, Identity Delegation et AI Agents : pourquoi AWS AgentCore et Azure Foundry ne suffisent pas encore en production
Article technique pour développeurs et architectes — stack Spring Boot / Spring Cloud Gateway, comparant AWS AgentCore, Azure Foundry Agent Service et Keycloak.
Suite partie 3
7. Solution 3 — Keycloak multi-cloud (la plus rigoureuse)
Pourquoi celle-ci
Keycloak depuis la version 26.2 (mai 2025) supporte le Standard Token Exchange RFC 8693 en GA. Combiné à un protocol mapper custom, on obtient le claim act et la vraie délégation. Multi-cloud, open source, auto-hébergé.

Architecture Keycloak détaillée — flux complet pas à pas avec tous les payloads
Le flux complet de bout en bout, avec les requêtes HTTP exactes, le payload du token OBO, le code agent qui lit l’identité combinée, et la politique côté MCP. Les 10 étapes mappent directement aux sections de code ci-dessous.
Setup Keycloak
Trois clients à créer dans le realm main :

Sur chaque client cible (gateway, agent runtime), activer le switch “Standard Token Exchange Enabled” dans l’admin console.
Sur le client frontend-app, ajouter une Client Policy qui n'autorise l'exchange que vers les audiences agentcore et salesforce-mcp.
Sur l’utilisateur Diaguily, ajouter un attribut custom may_act listant les service accounts autorisés à agir pour lui.
Le protocol mapper Java pour act
Ce mapper est la pièce centrale. Il lit l’actor_token validé et injecte le claim act dans le token sortant. À déposer comme JAR dans /opt/keycloak/providers/.
package com.acme.keycloak.mapper;
import org.keycloak.models.*;
import org.keycloak.protocol.oidc.mappers.*;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.IDToken;
import java.util.*;
public class ActClaimMapper extends AbstractOIDCProtocolMapper
implements OIDCAccessTokenMapper, OIDCIDTokenMapper {
public static final String PROVIDER_ID = "act-claim-mapper";
private static final List<ProviderConfigProperty> CONFIG = new ArrayList<>();
static {
OIDCAttributeMapperHelper.addTokenClaimNameConfig(CONFIG);
OIDCAttributeMapperHelper.addIncludeInTokensConfig(CONFIG, ActClaimMapper.class);
}
@Override public String getId() { return PROVIDER_ID; }
@Override public String getDisplayType() { return "Act Claim (RFC 8693)"; }
@Override public String getHelpText() {
return "Inject 'act' claim from validated actor_token during token exchange";
}
@Override public List<ProviderConfigProperty> getConfigProperties() { return CONFIG; }
@Override
protected void setClaim(IDToken token, ProtocolMapperModel mapperModel,
UserSessionModel userSession, KeycloakSession session,
ClientSessionContext clientSessionCtx) {
// Récupère l'actor_token validé par le moteur de token exchange
// (déposé en note de session par TokenExchangeProcessor)
String actorTokenJson = userSession.getNote("rfc8693:actor_token_validated");
if (actorTokenJson == null) return; // pas un token exchange
try {
AccessToken actorToken = session.tokens().decode(actorTokenJson, AccessToken.class);
if (actorToken == null) return;
Map<String, Object> actClaim = new LinkedHashMap<>();
actClaim.put("sub", actorToken.getSubject());
actClaim.put("iss", actorToken.getIssuer());
// Si l'actor_token avait déjà un act (chaîne de délégation), on l'imbrique
Object existingAct = actorToken.getOtherClaims().get("act");
if (existingAct != null) {
actClaim.put("act", existingAct);
}
token.getOtherClaims().put("act", actClaim);
} catch (Exception e) {
// Log et abort plutôt que de produire un token sans act
throw new RuntimeException("Failed to inject act claim", e);
}
}
}
Et le fichier META-INF/services/org.keycloak.protocol.ProtocolMapper :
com.acme.keycloak.mapper.ActClaimMapper
Build : mvn package, dépose le JAR dans /opt/keycloak/providers/, redémarre Keycloak. Le mapper apparaît dans la liste des protocol mappers de l'admin console — l'attacher au client gateway côté "Client Scopes".
Configuration Spring Gateway
application.yml :
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://kc.acme.com/realms/main
audiences: frontend-app
cloud:
gateway:
routes:
- id: agent-route
uri: https://agent-runtime.acme.com
predicates:
- Path=/api/agent/**
filters:
- name: KeycloakOboFilter
args:
token-endpoint: https://kc.acme.com/realms/main/protocol/openid-connect/token
gateway-client-id: gateway
gateway-client-secret: ${GATEWAY_SECRET}
actor-client-id: service-account-claude
actor-client-secret: ${ACTOR_SECRET}
target-audience: agentcore
target-scope: salesforce:read
Le filtre custom
@Component
public class KeycloakOboFilter extends AbstractGatewayFilterFactory<KeycloakOboFilter.Config> {
private final WebClient webClient = WebClient.builder()
.codecs(c -> c.defaultCodecs().maxInMemorySize(256 * 1024))
.build();
private final Cache<String, String> actorTokenCache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(4))
.maximumSize(10)
.build();
@Data
public static class Config {
private String tokenEndpoint;
private String gatewayClientId;
private String gatewayClientSecret;
private String actorClientId;
private String actorClientSecret;
private String targetAudience;
private String targetScope;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.cast(JwtAuthenticationToken.class)
.map(t -> t.getToken().getTokenValue())
.flatMap(userToken -> getActorToken(config)
.flatMap(actorToken -> exchangeForObo(userToken, actorToken, config))
.flatMap(oboToken -> forwardWithObo(exchange, chain, oboToken)));
}
/** Récupère un token client_credentials pour l'identité de l'agent (caché). */
private Mono<String> getActorToken(Config config) {
String cached = actorTokenCache.getIfPresent(config.getActorClientId());
if (cached != null) return Mono.just(cached);
return webClient.post()
.uri(config.getTokenEndpoint())
.header(HttpHeaders.AUTHORIZATION, basicAuth(
config.getActorClientId(), config.getActorClientSecret()))
.body(BodyInserters.fromFormData("grant_type", "client_credentials"))
.retrieve()
.bodyToMono(JsonNode.class)
.map(json -> json.get("access_token").asText())
.doOnNext(token -> actorTokenCache.put(config.getActorClientId(), token));
}
/** Le coeur : appel RFC 8693 token exchange. */
private Mono<String> exchangeForObo(String userToken, String actorToken, Config config) {
MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange");
form.add("subject_token", userToken);
form.add("subject_token_type", "urn:ietf:params:oauth:token-type:access_token");
form.add("actor_token", actorToken);
form.add("actor_token_type", "urn:ietf:params:oauth:token-type:access_token");
form.add("audience", config.getTargetAudience());
form.add("scope", config.getTargetScope());
return webClient.post()
.uri(config.getTokenEndpoint())
.header(HttpHeaders.AUTHORIZATION, basicAuth(
config.getGatewayClientId(), config.getGatewayClientSecret()))
.body(BodyInserters.fromFormData(form))
.retrieve()
.onStatus(HttpStatusCode::isError, this::mapKeycloakError)
.bodyToMono(JsonNode.class)
.map(json -> json.get("access_token").asText())
.timeout(Duration.ofSeconds(3))
.retryWhen(Retry.backoff(2, Duration.ofMillis(150))
.filter(this::isRetriable));
}
private Mono<Void> forwardWithObo(ServerWebExchange exchange,
GatewayFilterChain chain,
String oboToken) {
ServerHttpRequest mutated = exchange.getRequest().mutate()
.headers(h -> h.set(HttpHeaders.AUTHORIZATION, "Bearer " + oboToken))
.build();
return chain.filter(exchange.mutate().request(mutated).build());
}
private String basicAuth(String user, String pass) {
return "Basic " + Base64.getEncoder().encodeToString(
(user + ":" + pass).getBytes(StandardCharsets.UTF_8));
}
private Mono<Throwable> mapKeycloakError(ClientResponse resp) {
return resp.bodyToMono(String.class)
.map(body -> new ResponseStatusException(resp.statusCode(),
"Token exchange failed: " + body));
}
private boolean isRetriable(Throwable t) {
return t instanceof TimeoutException ||
(t instanceof ResponseStatusException rse && rse.getStatusCode().is5xxServerError());
}
}
Côté agent (Spring Boot)
Le code agent reçoit le token OBO et lit les deux identités :
@RestController
public class AgentController {
@PostMapping("/agent/invoke")
public Mono<ResponseEntity<String>> invoke(@AuthenticationPrincipal Jwt jwt,
@RequestBody PromptRequest req) {
String principal = jwt.getSubject(); // diaguily@acme.com
Map<String, Object> act = jwt.getClaim("act");
String actor = (String) act.get("sub"); // service-account-claude
List<String> scopes = jwt.getClaimAsStringList("scope"); // [salesforce:read]
AuditLogger.logAgent(principal, actor, scopes, req.getPrompt());
// Le code agent fait son travail, en sachant qu'il est limité au scope reçu
return agentRunner.run(req.getPrompt(), principal, actor, scopes);
}
}
Côté serveur MCP (politique fine)
@Component
public class McpAuthzPolicy {
public boolean canExecuteTool(Jwt token, String toolName, String operation) {
Map<String, Object> act = token.getClaim("act");
String actor = act != null ? (String) act.get("sub") : null;
boolean isAgent = actor != null && actor.startsWith("service-account-");
// Politique : si c'est un agent qui agit, refuser les opérations destructives
if (isAgent && Set.of("delete", "update", "send").contains(operation)) {
return false;
}
// Sinon, déléguer aux scopes du token
List<String> scopes = token.getClaimAsStringList("scope");
return scopes.contains(toolName + ":" + operation);
}
}
Avantages et limites
✅ Multi-cloud, fonctionne sur AWS, Azure, GCP, on-prem.
✅ Open source, auto-hébergé, pas de coût par utilisateur.
✅ RFC 8693 standard, claim act propre, chaîne de délégation possible.
✅ Politiques fines via Client Policies + Authorization Services.
✅ La même config marche pour tous les runtimes.
❌ Le mapper act est custom à écrire (~100 lignes Java) jusqu'au support natif.
❌ Tu héberges Keycloak — overhead opérationnel (HA, backup, upgrades).
❌ Pas de concept "Agent Identity" first-class comme Entra Agent ID.
8. Comparaison finale

Matrice de décision visuelle — quelle solution choisir selon le contexte
Vue synthétique des trois solutions sur 12 critères techniques et stratégiques, avec recommandation par contexte en bas. La version textuelle ci-dessous reprend les mêmes données pour ceux qui préfèrent un tableau lisible en accessibilité.

9. Recommandations finales
Si vous démarrez aujourd’hui
Vous êtes mono-cloud AWS, équipe AWS-centric, contraintes SOC 2 strictes → Solution AWS. Acceptez la limite du claims-as-headers, mais verrouillez très précisément les rôles IAM. C’est suffisant pour 80% des cas d’usage tant que vous n’avez pas besoin de chaînes agent → agent.
Vous êtes mono-cloud Azure, équipe Microsoft, déjà Entra ID → Solution Azure. L’OBO Entra ID est mature, l’intégration MSAL4J est propre, c’est le chemin de moindre résistance. Acceptez le verrou cloud.
Vous voulez du portable, du standard, du contrôle total, ou vous êtes multi-cloud → Solution Keycloak. C’est plus de travail au démarrage (le protocol mapper) mais c’est le seul chemin qui vous donne RFC 8693 partout. Et quand le support natif du act arrivera dans Keycloak (issue #38279), vous pourrez supprimer votre mapper custom sans casser la prod.
Pièges à éviter
- Ne pas confondre auth d’appelant et identité d’utilisateur. SigV4 prouve “qui est le caller AWS”, pas “qui est l’utilisateur métier”. Les deux questions ont des réponses différentes.
- Ne jamais passer une identité utilisateur en clair sans contrôle. Si vous utilisez claims-as-headers, scopez la permission d’invoquer l’agent à un seul rôle IAM — celui du gateway, et personne d’autre.
- TTL court sur les tokens OBO. 5–10 minutes max. Un token volé qui expire en 10 minutes fait peu de dégâts.
- Toujours
actpour les agents, jamais d'impersonation. Si votre STS supporte les deux (Curity, Auth0, Keycloak), choisissez la délégation. L'impersonation efface la responsabilité. - Audit côté MCP/outil, pas seulement côté agent. L’agent peut être compromis. Le serveur MCP doit logger ce qu’il a réellement reçu (sub + act + scope), pas ce que l’agent prétend.
- Ne pas oublier le rate limiting. Un agent boucle ou hallucine peut faire des milliers d’appels. Spring Cloud Gateway a
RequestRateLimiternatif (Redis backend), à activer dès le départ.
Ce qui va changer en 2026–2027
- Microsoft Entra Agent ID va probablement combler le gap actuel sur Azure (claim
actnatif). - AWS Verified Permissions étend ses capacités vers les agents — surveiller.
- Keycloak issue #38279 : support natif du claim
actattendu courant 2026. - Spec IETF “Identity Chaining Across Domains” se stabilise — Keycloak l’implémente déjà en preview depuis 26.5.
Quelle que soit la solution choisie aujourd’hui, mettez le gateway en place dès maintenant. Migrer la logique de validation d’un proxy sans gateway vers une architecture avec gateway est dix fois plus coûteux après que vous avez déployé en production.
Conclusion
Le gap d’authentification des agents IA n’est pas un problème de plomberie — c’est un problème d’architecture qui a des conséquences directes sur la sécurité, la compliance et l’auditabilité. Les plateformes managées (AWS AgentCore, Azure Foundry) résolvent l’exécution mais pas l’identité.
Spring Cloud Gateway est un bon choix de gateway pour cette tâche : il supporte la logique custom dont vous avez besoin (token exchange, SigV4, OBO Entra), s’intègre nativement à l’écosystème Spring Boot de vos agents, et reste programmable.
Trois solutions, trois compromis. Aucune n’est universellement meilleure. Le pire choix est de ne rien faire et de pousser un agent en production avec uniquement les modes natifs : c’est techniquement possible, mais c’est la dette de sécurité de demain.
Article rédigé en mai 2026. Toute correction ou retour d’expérience est bienvenu.
메타데이터
- post_id
- f93febffb609
- slug
- oauth-token-exchange-identity-delegation-et-ai-agents-pourquoi-aws-agentcore-et-azure-foundry-ne-f93febffb609
- url
- https://medium.com/@diaguilybouna/oauth-token-exchange-identity-delegation-et-ai-agents-pourquoi-aws-agentcore-et-azure-foundry-ne-f93febffb609
- canonical_url
- https://medium.com/@diaguilybouna/oauth-token-exchange-identity-delegation-et-ai-agents-pourquoi-aws-agentcore-et-azure-foundry-ne-f93febffb609
- author_url
- https://medium.com/@diaguilybouna
- status
- ok
- fetched_at
- 2026-07-11 16:53:25