← Back to list

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…

sylla diaguily · 2026-05-14 11:20 · 5 claps · 7.0 min read
#spring-boot #mcp-ai #ai #mcp-server #cloud-computing
Open on Medium ↗
Wiki topics: AGT · AI Agents AI · AI · General ☁️ · DevOps & Cloud 🏛️ · Architecture

[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

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

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

  1. 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.
  2. 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.
  3. TTL court sur les tokens OBO. 5–10 minutes max. Un token volé qui expire en 10 minutes fait peu de dégâts.
  4. Toujours act pour les agents, jamais d'impersonation. Si votre STS supporte les deux (Curity, Auth0, Keycloak), choisissez la délégation. L'impersonation efface la responsabilité.
  5. 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.
  6. Ne pas oublier le rate limiting. Un agent boucle ou hallucine peut faire des milliers d’appels. Spring Cloud Gateway a RequestRateLimiter natif (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 act natif).
  • AWS Verified Permissions étend ses capacités vers les agents — surveiller.
  • Keycloak issue #38279 : support natif du claim act attendu 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