Skip to content

Add Authentication Support for Entra Agent ID Container Side Car #606

Description

@MattB-msft

Entra ID Auth Sidecar Provider — Microsoft 365 Agents SDK

Summary

Add a new authentication provider to the Microsoft 365 Agents SDK (all languages) that integrates with the Microsoft Entra ID Agent Container (sidecar). This provider implements the existing token provider interfaces by delegating token acquisition to the sidecar's HTTP API rather than directly using MSAL libraries.

Initial Scope: Acquire the base Blueprint Agentic Identity token from the sidecar container, then leverage the existing SDK resolution path for Agent Instance and Agent User tokens.


Motivation

The Entra ID Agent Container provides a language-agnostic, credential-free authentication sidecar that:

  1. Eliminates credential handling in agent code — The agent never touches secrets, certificates, or keys. All credential management is offloaded to the sidecar.
  2. Simplifies multi-language support — The sidecar exposes a simple HTTP API, removing the need for each language SDK to independently implement complex MSAL flows.
  3. Provides production-grade security — The sidecar supports Workload Identity, Managed Identity, Key Vault certificates, and federated credentials out of the box.
  4. Enables consistent local development — Docker Compose with the sidecar provides the same authentication experience locally as in production.

By adding this as an authentication provider in the Agents SDK, developers can seamlessly adopt the sidecar pattern while maintaining full compatibility with the SDK's existing agentic identity resolution pipeline.


Scope

In Scope (Phase 1)

Capability Description
Blueprint Identity Acquisition Acquire the base Blueprint app token via GET /AuthorizationHeaderUnauthenticated/{name}
Agent Instance Resolution Pass the Blueprint token through the existing SDK path to resolve the Agent Instance identity
Agent User Resolution Pass the Blueprint token through the existing SDK path to resolve the Agent User identity
Token Provider Interface Implementation Full implementation of the language-specific token provider interface delegating to the sidecar
Connection Manager Implementation Connection registry that dispatches to the sidecar provider
Configuration model Sidecar-specific config using SIDECAR_URL environment variable
Health check Validate sidecar availability via GET /healthz at startup

Out of Scope (Future Phases)

Capability Rationale
OBO token exchange via sidecar Requires inbound user token; Phase 2 after Blueprint flow is proven
Downstream API proxy (/DownstreamApi/* endpoints) SDK does not currently proxy API calls through auth providers
Token validation (/Validate endpoint) SDK handles inbound token validation separately
OBO Exchange implementation via sidecar Deferred to Phase 2

Architecture

┌─────────────────────────────────────────────────────────┐
│  Agent Application                                       │
│                                                          │
│  ┌──────────────────────────────────────────────────┐   │
│  │  Agents SDK                                       │   │
│  │                                                   │   │
│  │  TokenProvider Interface ◄── SidecarAuthProvider   │   │
│  │         │                                         │   │
│  │         │ GetAgenticApplicationToken()             │   │
│  │         ▼                                         │   │
│  │  ┌─────────────────────────┐                      │   │
│  │  │ HTTP Client             │ ─── HTTP ───┐        │   │
│  │  └─────────────────────────┘              │       │   │
│  └───────────────────────────────────────────┼───────┘   │
│                                              │           │
└──────────────────────────────────────────────┼───────────┘
                                               │
                                               ▼
┌──────────────────────────────────────────────────────────┐
│  Entra ID Agent Container (Sidecar)         :5000        │
│                                                          │
│  GET /AuthorizationHeaderUnauthenticated/{name}          │
│      ?AgentIdentity={agent-client-id}                    │
│                                                          │
│  Response: { "authorizationHeader": "Bearer <token>" }   │
│                                                          │
│  Credentials: Managed Identity / Workload Identity /     │
│               Client Secret (dev) / Key Vault Cert       │
└──────────────────────────────────────────────────────────┘
                       │
                       ▼
          Microsoft Entra ID (login.microsoftonline.com)

Token Flow — Blueprint Identity (Phase 1 Focus)

Agent SDK                          Sidecar (:5000)                    Entra ID
   │                                    │                                │
   │ GetAgenticApplicationToken()       │                                │
   │──────────────────────────────────►│                                │
   │  GET /AuthorizationHeaderUnauthenticated/default                    │
   │      ?AgentIdentity={agentAppInstanceId}                            │
   │                                    │                                │
   │                                    │ Client Credentials (Blueprint) │
   │                                    │───────────────────────────────►│
   │                                    │◄─────────── T1 (Blueprint) ────│
   │                                    │                                │
   │                                    │ FIC Exchange (AgentIdentity)   │
   │                                    │───────────────────────────────►│
   │                                    │◄─────────── TR (Agent App) ────│
   │                                    │                                │
   │◄─── { "authorizationHeader":       │                                │
   │       "Bearer TR" }                │                                │
   │                                    │                                │
   │ Parse → strip "Bearer " prefix     │                                │
   │ return TR                          │                                │
   │                                    │                                │
   │ ─── Existing SDK path ───          │                                │
   │ GetAgenticInstanceToken()          │                                │
   │   uses TR as client_assertion      │                                │
   │   to acquire Agent Instance token  │                                │
   │                                    │                                │
   │ GetAgenticUserToken()              │                                │
   │   uses Instance token + user_fic   │                                │
   │   grant to acquire user token      │                                │

Key Insight: The sidecar provides the Blueprint application token (the first step in the agentic identity chain). The existing SDK logic for Instance and User token resolution already knows how to use a Blueprint token to derive those tokens — this provider just changes how the initial Blueprint token is acquired.


Parameter Resolution from ActivityProtocol

The AgentIdentity (agent instance ID) and AgentUserId (agent object ID) values are not stored in configuration — they are extracted from the inbound ActivityProtocol request at runtime:

Parameter Source in ActivityProtocol Request Passed To
Agent App Instance ID The Agent Instance ID from the activity request GetAgenticApplicationToken, GetAgenticInstanceToken, GetAgenticUserToken — mapped to the AgentIdentity query parameter on the sidecar
Agent User ID The Agent User object ID from the activity request GetAgenticUserToken — mapped to the AgentUserId query parameter on the sidecar

This means the SDK resolves identity per-request based on the incoming activity, enabling a single agent deployment to serve multiple agent identities and users without reconfiguration.


Base Interfaces by Language

The new provider implements the existing token provider interface in each language.

.NET
// IAccessTokenProvider — Primary SDK contract
public interface IAccessTokenProvider
{
    Task<string> GetAccessTokenAsync(string resourceUrl, IList<string> scopes, bool forceRefresh = false);
    TokenCredential GetTokenCredential();
    ImmutableConnectionSettings ConnectionSettings { get; }
}

// IAgenticTokenProvider — Agentic/Blueprint identity flows
public interface IAgenticTokenProvider
{
    Task<string> GetAgenticApplicationTokenAsync(string tenantId, string agentAppInstanceId, CancellationToken cancellationToken = default);
    Task<string> GetAgenticInstanceTokenAsync(string tenantId, string agentAppInstanceId, CancellationToken cancellationToken = default);
    Task<string> GetAgenticUserTokenAsync(string tenantId, string agentAppInstanceId, string upn, IList<string> scopes, CancellationToken cancellationToken = default);
}

// IConnections — Named provider registry
public interface IConnections
{
    IAccessTokenProvider GetConnection(string name);
    bool TryGetConnection(string name, out IAccessTokenProvider connection);
    IAccessTokenProvider GetDefaultConnection();
    IAccessTokenProvider GetTokenProvider(ClaimsIdentity claimsIdentity, string serviceUrl);
    IAccessTokenProvider GetTokenProvider(ClaimsIdentity claimsIdentity, IActivity activity);
}
Python
class AccessTokenProviderBase(Protocol):

    @abstractmethod
    async def get_access_token(
        self, resource_url: str, scopes: list[str], force_refresh: bool = False
    ) -> str:
        pass

    async def acquire_token_on_behalf_of(
        self, scopes: list[str], user_assertion: str
    ) -> str:
        raise NotImplementedError()

    async def get_agentic_application_token(
        self, tenant_id: str, agent_app_instance_id: str
    ) -> Optional[str]:
        raise NotImplementedError()

    async def get_agentic_instance_token(
        self, tenant_id: str, agent_app_instance_id: str
    ) -> tuple[str, str]:
        raise NotImplementedError()

    async def get_agentic_user_token(
        self,
        tenant_id: str,
        agent_app_instance_id: str,
        agentic_user_id: str,
        scopes: list[str],
    ) -> Optional[str]:
        raise NotImplementedError()


class Connections(Protocol):

    @abstractmethod
    def get_connection(self, connection_name: str) -> AccessTokenProviderBase: ...

    @abstractmethod
    def get_default_connection(self) -> AccessTokenProviderBase: ...

    @abstractmethod
    def get_token_provider(
        self, claims_identity: ClaimsIdentity, service_url: str
    ) -> AccessTokenProviderBase: ...

    @abstractmethod
    def get_default_connection_configuration(self) -> AgentAuthConfiguration: ...
TypeScript
export interface AuthProvider {
  connectionSettings?: AuthConfiguration

  getAccessToken(authConfig: AuthConfiguration, scope: string): Promise<string>
  getAccessToken(scope: string): Promise<string>
  getAccessToken(authConfigOrScope: AuthConfiguration | string, scope?: string): Promise<string>

  getAgenticApplicationToken: (tenantId: string, agentAppInstanceId: string) => Promise<string>
  getAgenticInstanceToken: (tenantId: string, agentAppInstanceId: string) => Promise<string>
  getAgenticUserToken: (
    tenantId: string,
    agentAppInstanceId: string,
    upn: string,
    scopes: string[]
  ) => Promise<string>

  acquireTokenOnBehalfOf(scopes: string[], oboAssertion: string): Promise<string>
  acquireTokenOnBehalfOf(authConfig: AuthConfiguration, scopes: string[], oboAssertion: string): Promise<string>
  acquireTokenOnBehalfOf(
    authConfigOrScopes: AuthConfiguration | string[],
    scopesOrOboAssertion?: string[] | string,
    oboAssertion?: string
  ): Promise<string>
}

export interface Connections {
  getConnection(name: string): AuthProvider
  getDefaultConnection(): AuthProvider
  getTokenProvider(identity: JwtPayload, serviceUrl: string): AuthProvider
  getTokenProviderFromActivity(identity: JwtPayload, activity: Activity): AuthProvider
  getDefaultConnectionConfiguration(): AuthConfiguration
}

Provider Implementation

.NET

Class Declaration

public class SidecarAuth : IAccessTokenProvider, IAgenticTokenProvider
{
    private readonly SidecarTokenClient _tokenClient;
    private readonly SidecarConnectionSettings _settings;

    public SidecarAuth(IServiceProvider serviceProvider, IConfigurationSection configSection)
    {
        _settings = new SidecarConnectionSettings(configSection);
        _tokenClient = serviceProvider.GetRequiredService<SidecarTokenClient>();
    }

    public ImmutableConnectionSettings ConnectionSettings => new ImmutableConnectionSettings(_settings);

    public async Task<string> GetAccessTokenAsync(string resourceUrl, IList<string> scopes, bool forceRefresh = false)
    {
        var response = await _tokenClient.GetAuthorizationHeaderUnauthenticatedAsync(
            agentIdentityClientId: null,
            cancellationToken: default
        );
        return response;
    }

    public TokenCredential GetTokenCredential()
    {
        return new SidecarTokenCredential(this);
    }

    public async Task<string> GetAgenticApplicationTokenAsync(
        string tenantId, string agentAppInstanceId, CancellationToken cancellationToken = default)
    {
        return await _tokenClient.GetAuthorizationHeaderUnauthenticatedAsync(
            agentIdentityClientId: agentAppInstanceId,
            cancellationToken: cancellationToken
        );
    }

    public async Task<string> GetAgenticInstanceTokenAsync(
        string tenantId, string agentAppInstanceId, CancellationToken cancellationToken = default)
    {
        // Step 1: Get Blueprint token from sidecar
        var appToken = await GetAgenticApplicationTokenAsync(tenantId, agentAppInstanceId, cancellationToken);

        // Step 2: Existing SDK path — exchange for Instance token
        var cca = ConfidentialClientApplicationBuilder
            .Create(agentAppInstanceId)
            .WithAuthority($"https://login.microsoftonline.com/{tenantId}")
            .WithClientAssertion(appToken)
            .Build();

        var result = await cca
            .AcquireTokenForClient(new[] { "api://AzureAdTokenExchange/.default" })
            .ExecuteAsync(cancellationToken);

        return result.AccessToken;
    }

    public async Task<string> GetAgenticUserTokenAsync(
        string tenantId, string agentAppInstanceId, string upn,
        IList<string> scopes, CancellationToken cancellationToken = default)
    {
        var appToken = await GetAgenticApplicationTokenAsync(tenantId, agentAppInstanceId, cancellationToken);
        var instanceToken = await GetAgenticInstanceTokenAsync(tenantId, agentAppInstanceId, cancellationToken);

        // Exchange for user token via user_fic grant
        var cca = ConfidentialClientApplicationBuilder
            .Create(agentAppInstanceId)
            .WithAuthority($"https://login.microsoftonline.com/{tenantId}")
            .WithClientAssertion(appToken)
            .Build();

        var result = await cca
            .AcquireTokenForClient(scopes)
            .WithExtraParameters(new Dictionary<string, string>
            {
                ["user_id"] = upn,
                ["user_federated_identity_credential"] = instanceToken,
                ["grant_type"] = "user_fic"
            })
            .ExecuteAsync(cancellationToken);

        return result.AccessToken;
    }
}

Internal Token Client

internal class SidecarTokenClient : IDisposable
{
    private readonly HttpClient _httpClient;

    public SidecarTokenClient(IHttpClientFactory httpClientFactory)
    {
        _httpClient = httpClientFactory.CreateClient("EntraAuthSideCarClient");
    }

    public async Task<string> GetAuthorizationHeaderUnauthenticatedAsync(
        string agentIdentityClientId,
        CancellationToken cancellationToken = default)
    {
        var url = "/AuthorizationHeaderUnauthenticated/default";
        if (!string.IsNullOrEmpty(agentIdentityClientId))
        {
            url += $"?AgentIdentity={Uri.EscapeDataString(agentIdentityClientId)}";
        }

        var response = await _httpClient.GetAsync(url, cancellationToken);
        response.EnsureSuccessStatusCode();

        var content = await response.Content.ReadAsStringAsync(cancellationToken);
        var result = JsonSerializer.Deserialize<SidecarAuthResponse>(content);

        var token = result.AuthorizationHeader;
        if (token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
        {
            token = token.Substring(7);
        }
        return token;
    }

    public async Task<bool> IsHealthyAsync(CancellationToken cancellationToken = default)
    {
        try
        {
            var response = await _httpClient.GetAsync("/healthz", cancellationToken);
            return response.IsSuccessStatusCode;
        }
        catch { return false; }
    }
}

DI Registration

public static class SidecarServiceCollectionExtensions
{
    public static IServiceCollection AddEntraAuthSideCar(
        this IServiceCollection services,
        IConfiguration configuration,
        string configSectionName = "EntraAuthSideCar")
    {
        var section = configuration.GetSection(configSectionName);
        services.Configure<SidecarConnectionSettings>(section);

        services.AddHttpClient("EntraAuthSideCarClient", (sp, client) =>
        {
            var settings = sp.GetRequiredService<IOptions<SidecarConnectionSettings>>().Value;
            // Resolution order: SIDECAR_URL env var > SidecarBaseUrl config > default
            client.BaseAddress = new Uri(settings.ResolvedSidecarBaseUrl);
            client.Timeout = settings.RequestTimeout;
        })
        .AddTransientHttpErrorPolicy(p =>
            p.WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt))));

        services.AddSingleton<SidecarTokenClient>();
        services.AddSingleton<IAccessTokenProvider, SidecarAuth>();
        services.AddSingleton<IAgenticTokenProvider, SidecarAuth>();

        return services;
    }
}
Python

Class Declaration

import os
import logging
from typing import Optional
import httpx
from msal import ConfidentialClientApplication

from microsoft_agents.hosting.core import (
    AccessTokenProviderBase,
    AgentAuthConfiguration,
)

logger = logging.getLogger(__name__)


class SidecarAuth(AccessTokenProviderBase):
    """
    Authentication provider that delegates token acquisition to the
    Microsoft Entra ID Agent Container (sidecar).
    """

    def __init__(self, configuration: AgentAuthConfiguration):
        self._configuration = configuration
        # Resolution order: SIDECAR_URL env var > sidecar_base_url config > default
        self._sidecar_base_url = (
            os.environ.get("SIDECAR_URL")
            or getattr(configuration, "sidecar_base_url", None)
            or "http://localhost:5000"
        )
        self._http_client = httpx.AsyncClient(
            base_url=self._sidecar_base_url,
            timeout=httpx.Timeout(30.0),
        )

    async def get_access_token(
        self, resource_url: str, scopes: list[str], force_refresh: bool = False
    ) -> str:
        """Acquire an app-only access token from the sidecar."""
        response = await self._http_client.get(
            "/AuthorizationHeaderUnauthenticated/default"
        )
        response.raise_for_status()
        return self._parse_token(response.json())

    async def get_agentic_application_token(
        self, tenant_id: str, agent_app_instance_id: str
    ) -> Optional[str]:
        """
        Acquire the Blueprint agentic identity token from the sidecar.
        The agent_app_instance_id comes from the inbound ActivityProtocol request.
        """
        response = await self._http_client.get(
            "/AuthorizationHeaderUnauthenticated/default",
            params={"AgentIdentity": agent_app_instance_id},
        )
        response.raise_for_status()
        return self._parse_token(response.json())

    async def get_agentic_instance_token(
        self, tenant_id: str, agent_app_instance_id: str
    ) -> tuple[str, str]:
        """
        Acquire the Agent Instance token.
        Uses Blueprint token from sidecar, then existing SDK path.
        """
        # Step 1: Get Blueprint token from sidecar
        agent_token = await self.get_agentic_application_token(
            tenant_id, agent_app_instance_id
        )
        if not agent_token:
            raise RuntimeError(
                f"Failed to acquire agentic application token for {agent_app_instance_id}"
            )

        # Step 2: Existing SDK path — exchange for Instance token
        instance_app = ConfidentialClientApplication(
            client_id=agent_app_instance_id,
            authority=f"https://login.microsoftonline.com/{tenant_id}",
            client_credential={"client_assertion": agent_token},
        )
        result = instance_app.acquire_token_for_client(
            scopes=["api://AzureAdTokenExchange/.default"]
        )
        if "access_token" not in result:
            raise RuntimeError(
                f"Failed to acquire instance token: {result.get('error_description', 'unknown')}"
            )
        return (result["access_token"], agent_token)

    async def get_agentic_user_token(
        self,
        tenant_id: str,
        agent_app_instance_id: str,
        agentic_user_id: str,
        scopes: list[str],
    ) -> Optional[str]:
        """
        Acquire a user-scoped token using the agentic identity chain.
        Both agent_app_instance_id and agentic_user_id come from the
        inbound ActivityProtocol request.
        """
        instance_token, agent_token = await self.get_agentic_instance_token(
            tenant_id, agent_app_instance_id
        )

        # Exchange for user token via user_fic grant
        user_app = ConfidentialClientApplication(
            client_id=agent_app_instance_id,
            authority=f"https://login.microsoftonline.com/{tenant_id}",
            client_credential={"client_assertion": agent_token},
        )
        result = user_app.acquire_token_for_client(
            scopes=scopes,
            data={
                "user_id": agentic_user_id,
                "user_federated_identity_credential": instance_token,
                "grant_type": "user_fic",
            },
        )
        if "access_token" not in result:
            raise RuntimeError(
                f"Failed to acquire user token: {result.get('error_description', 'unknown')}"
            )
        return result["access_token"]

    async def is_healthy(self) -> bool:
        """Check sidecar availability via /healthz endpoint."""
        try:
            response = await self._http_client.get("/healthz")
            return response.status_code == 200
        except httpx.HTTPError:
            return False

    async def close(self):
        """Close the underlying HTTP client."""
        await self._http_client.aclose()

    @staticmethod
    def _parse_token(response_body: dict) -> str:
        """Extract raw access token from sidecar response."""
        auth_header = response_body.get("authorizationHeader", "")
        if auth_header.startswith("Bearer "):
            return auth_header[7:]
        return auth_header
TypeScript

Class Declaration

import { AuthProvider } from './authProvider'
import { AuthConfiguration } from './authConfiguration'
import { ConfidentialClientApplication } from '@azure/msal-node'
import { MemoryCache } from './MemoryCache'

export class SidecarAuthProvider implements AuthProvider {
  public readonly connectionSettings?: AuthConfiguration
  private readonly _sidecarBaseUrl: string
  private readonly _agenticTokenCache: MemoryCache<string>

  constructor(connectionSettings?: AuthConfiguration) {
    this.connectionSettings = connectionSettings
    // Resolution order: SIDECAR_URL env var > sidecarBaseUrl config > default
    this._sidecarBaseUrl = process.env.SIDECAR_URL
      ?? connectionSettings?.sidecarBaseUrl
      ?? 'http://localhost:5000'
    this._agenticTokenCache = new MemoryCache<string>()
  }

  /**
   * Acquire an app-only access token from the sidecar.
   */
  async getAccessToken(authConfigOrScope: AuthConfiguration | string, scope?: string): Promise<string> {
    const response = await fetch(
      `${this._sidecarBaseUrl}/AuthorizationHeaderUnauthenticated/default`
    )

    if (!response.ok) {
      throw new Error(`Sidecar token acquisition failed: ${response.status} ${response.statusText}`)
    }

    const body = await response.json()
    return this._parseToken(body)
  }

  /**
   * Acquire the Blueprint agentic identity token from the sidecar.
   * The agentAppInstanceId comes from the inbound ActivityProtocol request.
   */
  async getAgenticApplicationToken(tenantId: string, agentAppInstanceId: string): Promise<string> {
    const cacheKey = `app/${agentAppInstanceId}`
    const cached = this._agenticTokenCache.get(cacheKey)
    if (cached) return cached

    const url = new URL(`${this._sidecarBaseUrl}/AuthorizationHeaderUnauthenticated/default`)
    url.searchParams.set('AgentIdentity', agentAppInstanceId)

    const response = await fetch(url.toString())
    if (!response.ok) {
      throw new Error(`Sidecar agentic app token failed: ${response.status} ${response.statusText}`)
    }

    const body = await response.json()
    const token = this._parseToken(body)
    this._agenticTokenCache.set(cacheKey, token, 300)
    return token
  }

  /**
   * Acquire the Agent Instance token.
   * Uses Blueprint token from sidecar, then existing SDK path.
   */
  async getAgenticInstanceToken(tenantId: string, agentAppInstanceId: string): Promise<string> {
    const cacheKey = `instance/${tenantId}/${agentAppInstanceId}`
    const cached = this._agenticTokenCache.get(cacheKey)
    if (cached) return cached

    // Step 1: Get Blueprint token from sidecar
    const appToken = await this.getAgenticApplicationToken(tenantId, agentAppInstanceId)

    // Step 2: Existing SDK path — exchange for Instance token
    const cca = new ConfidentialClientApplication({
      auth: {
        clientId: agentAppInstanceId,
        authority: `https://login.microsoftonline.com/${tenantId}`,
        clientAssertion: appToken
      }
    })

    const result = await cca.acquireTokenByClientCredential({
      scopes: ['api://AzureAdTokenExchange/.default']
    })

    if (!result?.accessToken) {
      throw new Error(`Failed to acquire agentic instance token for ${agentAppInstanceId}`)
    }

    const ttl = (result.expiresOn?.getTime() ?? Date.now() + 300000 - Date.now()) / 1000 - 300
    this._agenticTokenCache.set(cacheKey, result.accessToken, ttl)
    return result.accessToken
  }

  /**
   * Acquire a user-scoped token using the agentic identity chain.
   * Both agentAppInstanceId and agenticUserId come from the ActivityProtocol request.
   */
  async getAgenticUserToken(
    tenantId: string,
    agentAppInstanceId: string,
    agenticUserId: string,
    scopes: string[]
  ): Promise<string> {
    const appToken = await this.getAgenticApplicationToken(tenantId, agentAppInstanceId)
    const instanceToken = await this.getAgenticInstanceToken(tenantId, agentAppInstanceId)

    // Exchange for user token via user_fic grant
    const authority = `https://login.microsoftonline.com/${tenantId}`
    const body = new URLSearchParams({
      client_id: agentAppInstanceId,
      scope: scopes.join(' '),
      grant_type: 'user_fic',
      user_id: agenticUserId,
      user_federated_identity_credential: instanceToken,
      client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
      client_assertion: appToken
    })

    const response = await fetch(`${authority}/oauth2/v2.0/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' },
      body: body.toString()
    })

    if (!response.ok) {
      const errorBody = await response.text()
      throw new Error(`Failed to acquire agentic user token: ${errorBody}`)
    }

    const result = await response.json()
    return result.access_token
  }

  /**
   * OBO flow — not supported in Phase 1.
   */
  async acquireTokenOnBehalfOf(
    authConfigOrScopes: AuthConfiguration | string[],
    scopesOrOboAssertion?: string[] | string,
    oboAssertion?: string
  ): Promise<string> {
    throw new Error('acquireTokenOnBehalfOf not supported by Sidecar provider in Phase 1')
  }

  async isHealthy(): Promise<boolean> {
    try {
      const response = await fetch(`${this._sidecarBaseUrl}/healthz`)
      return response.ok
    } catch { return false }
  }

  private _parseToken(responseBody: { authorizationHeader?: string }): string {
    const authHeader = responseBody.authorizationHeader ?? ''
    if (authHeader.startsWith('Bearer ')) {
      return authHeader.substring(7)
    }
    if (!authHeader) {
      throw new Error('Sidecar response missing authorizationHeader field')
    }
    return authHeader
  }
}

Connection Manager

.NET

The .NET SDK uses IConnections for connection dispatch. The sidecar provider registers via DI and the existing ConfigurationConnections class resolves providers by name. No custom connection manager is needed — just DI registration (see DI Registration above).

The CloudAdapter resolves the token provider via:

var tokenProvider = connections.GetTokenProvider(claimsIdentity, activity);
Python

Rather than creating a sidecar-specific connection manager, the existing MsalConnectionManager should be refactored into a generic ConnectionManager that accepts any AccessTokenProviderBase implementation via a provider_factory parameter. This enables support for additional auth providers in the future without duplicating connection routing logic.

Rationale: The current MsalConnectionManager contains ~140 lines of connection routing logic (audience matching, service URL regex dispatch) that is entirely provider-agnostic. The only MSAL-specific line is MsalAuth(config). Refactoring avoids duplication for every new auth provider.

import re
from typing import Optional, Dict, List, Callable
from microsoft_agents.hosting.core import (
    Connections,
    AccessTokenProviderBase,
    AgentAuthConfiguration,
    ClaimsIdentity,
)


class ConnectionManager(Connections):
    """
    Generic connection manager that dispatches to any AccessTokenProviderBase
    implementation. The provider_factory determines which auth provider is
    instantiated per connection, enabling future providers without new managers.
    """

    def __init__(
        self,
        provider_factory: Callable[[AgentAuthConfiguration], AccessTokenProviderBase],
        connections_configurations: Optional[Dict[str, AgentAuthConfiguration]] = None,
        connections_map: Optional[List[Dict[str, str]]] = None,
        **kwargs,
    ):
        self._provider_factory = provider_factory
        self._connections: Dict[str, AccessTokenProviderBase] = {}
        self._connections_map = connections_map or kwargs.get("CONNECTIONSMAP", {})
        self._config_map: Dict[str, AgentAuthConfiguration] = {}

        if connections_configurations:
            for name, config in connections_configurations.items():
                self._connections[name] = provider_factory(config)
                self._config_map[name] = config
        else:
            raw_configurations: Dict[str, Dict] = kwargs.get("CONNECTIONS", {})
            for name, settings in raw_configurations.items():
                parsed_config = AgentAuthConfiguration(**settings.get("SETTINGS", {}))
                self._connections[name] = provider_factory(parsed_config)
                self._config_map[name] = parsed_config

        for config in self._config_map.values():
            config._connections = self._config_map

        if "SERVICE_CONNECTION" not in self._connections:
            raise ValueError("No SERVICE_CONNECTION configuration provided.")

    def get_connection(self, connection_name: Optional[str]) -> AccessTokenProviderBase:
        connection_name = connection_name or "SERVICE_CONNECTION"
        connection = self._connections.get(connection_name)
        if not connection:
            raise ValueError(f"No connection found for '{connection_name}'.")
        return connection

    def get_default_connection(self) -> AccessTokenProviderBase:
        return self.get_connection("SERVICE_CONNECTION")

    def get_token_provider(
        self, claims_identity: ClaimsIdentity, service_url: str
    ) -> AccessTokenProviderBase:
        if not self._connections_map:
            return self.get_default_connection()

        aud = (claims_identity.get_app_id() or "").lower()
        for item in self._connections_map:
            item_aud = item.get("AUDIENCE", "").lower()
            if item_aud and item_aud != aud:
                continue
            item_svc = item.get("SERVICEURL", "*")
            if item_svc == "*" or not item_svc:
                return self.get_connection(item.get("CONNECTION"))
            if re.match(item_svc, service_url, re.IGNORECASE):
                return self.get_connection(item.get("CONNECTION"))

        return self.get_default_connection()

    def get_default_connection_configuration(self) -> AgentAuthConfiguration:
        config = self._config_map.get("SERVICE_CONNECTION")
        if not config:
            raise ValueError("No SERVICE_CONNECTION configuration found.")
        return config

Usage with Sidecar:

from microsoft_agents.hosting.core import ConnectionManager
from microsoft_agents.authentication.entra_sidecar import SidecarAuth

connection_manager = ConnectionManager(
    provider_factory=SidecarAuth,
    CONNECTIONS=agents_sdk_config["CONNECTIONS"],
)

Backward-compatible MsalConnectionManager:

class MsalConnectionManager(ConnectionManager):
    def __init__(self, connections_configurations=None, connections_map=None, **kwargs):
        super().__init__(provider_factory=MsalAuth, connections_configurations=connections_configurations, connections_map=connections_map, **kwargs)
TypeScript

Same approach as Python — refactor MsalConnectionManager into a generic ConnectionManager that accepts a provider factory.

import { JwtPayload } from 'jsonwebtoken'
import { Activity, RoleTypes } from '@microsoft/agents-activity'
import { Connections } from './connections'
import { AuthProvider } from './authProvider'
import { AuthConfiguration, ConnectionMapItem } from './authConfiguration'

export type AuthProviderFactory = (config: AuthConfiguration) => AuthProvider

export class ConnectionManager implements Connections {
  private _connections: Map<string, AuthProvider> = new Map()
  private _connectionsMap: ConnectionMapItem[] = []
  private _serviceConnectionConfiguration: AuthConfiguration
  private static readonly DEFAULT_CONNECTION = 'serviceConnection'

  constructor (
    providerFactory: AuthProviderFactory,
    connectionsConfigurations: Map<string, AuthConfiguration> = new Map(),
    connectionsMap: ConnectionMapItem[] = [],
    configuration: AuthConfiguration = {}
  ) {
    this._serviceConnectionConfiguration = {}
    const providedConnections = connectionsConfigurations.size > 0
      ? connectionsConfigurations
      : (configuration.connections || new Map())

    this._connectionsMap = connectionsMap.length > 0
      ? connectionsMap
      : (configuration.connectionsMap || [])

    for (const [name, config] of providedConnections) {
      this._connections.set(name, providerFactory(config))
      if (name === ConnectionManager.DEFAULT_CONNECTION) {
        this._serviceConnectionConfiguration = config
      }
    }

    if (!this._connections.has(ConnectionManager.DEFAULT_CONNECTION)) {
      this._connections.set(ConnectionManager.DEFAULT_CONNECTION, providerFactory(configuration))
      this._serviceConnectionConfiguration = configuration
    }
  }

  getConnection (name: string): AuthProvider {
    const conn = this._connections.get(name)
    if (!conn) throw new Error(`Connection not found: ${name}`)
    return conn
  }

  getDefaultConnection (): AuthProvider { return this.getConnection(ConnectionManager.DEFAULT_CONNECTION) }

  getTokenProvider (identity: JwtPayload, serviceUrl: string): AuthProvider {
    const audience = Array.isArray(identity?.aud) ? identity.aud[0] : identity.aud
    for (const item of this._connectionsMap) {
      let audienceMatch = true
      if (item.audience && audience) audienceMatch = item.audience === audience
      if (audienceMatch) {
        if (item.serviceUrl === '*' || !item.serviceUrl) return this.getConnection(item.connection)
        if (new RegExp(item.serviceUrl, 'i').test(serviceUrl)) return this.getConnection(item.connection)
      }
    }
    return this.getDefaultConnection()
  }

  getTokenProviderFromActivity (identity: JwtPayload, activity: Activity): AuthProvider {
    const connection = this.getTokenProvider(identity, activity.serviceUrl || '')
    if (activity.recipient?.role === RoleTypes.AgenticIdentity || activity.recipient?.role === RoleTypes.AgenticUser) {
      const altName = connection.connectionSettings?.altBlueprintConnectionName?.trim()
      if (altName) return this.getConnection(altName)
    }
    return connection
  }

  getDefaultConnectionConfiguration (): AuthConfiguration { return this._serviceConnectionConfiguration }
}

Usage with Sidecar:

import { ConnectionManager } from '@microsoft/agents-hosting'
import { SidecarAuthProvider } from './sidecarAuthProvider'

const adapter = new CloudAdapter(authConfig)
adapter.connectionManager = new ConnectionManager(
  (config) => new SidecarAuthProvider(config),
  undefined, undefined, authConfig
)

Backward-compatible MsalConnectionManager:

export class MsalConnectionManager extends ConnectionManager {
  constructor (connectionsConfigurations?, connectionsMap?, configuration?) {
    super((config) => new MsalTokenProvider(config), connectionsConfigurations, connectionsMap, configuration)
  }
}

Configuration

Environment Variables (All Languages)

Variable Required Default Description
SIDECAR_URL No http://localhost:5000 Sidecar HTTP endpoint. Takes precedence over any SidecarBaseUrl / sidecarBaseUrl / sidecar_base_url config setting if both are present.

URL Resolution Precedence (all languages):

  1. SIDECAR_URL environment variable (highest priority)
  2. SidecarBaseUrl from SDK configuration (optional)
  3. Default: http://localhost:5000

SDK Configuration

Required fields (all languages): Only ClientId and Scopes are required. All other settings are optional and have sensible defaults.

.NET
{
  "Connections": {
    "BotServiceConnection": {
      "Type": "EntraAuthSideCar",
      "Settings": {
        "ClientId": "00000000-0000-0000-0000-000000000000",
        "Scopes": [ "api://00000000-0000-0000-0000-000000000000/.default" ],
        "SidecarBaseUrl": "http://localhost:5000",
        "RequestTimeout": "00:00:30",
        "RetryCount": 3
      }
    }
  }
}

Environment variable style configuration (double-underscore pattern):

Connections__BotServiceConnection__Type=EntraAuthSideCar
Connections__BotServiceConnection__Settings__ClientId=00000000-0000-0000-0000-000000000000
Connections__BotServiceConnection__Settings__Scopes__0=api://00000000-0000-0000-0000-000000000000/.default
Connections__BotServiceConnection__Settings__SidecarBaseUrl=http://my-sidecar:5000  # optional config fallback
Connections__BotServiceConnection__Settings__RequestTimeout=00:00:30
Connections__BotServiceConnection__Settings__RetryCount=3
SIDECAR_URL=http://localhost:5000  # takes precedence over SidecarBaseUrl if set

Configuration model:

public class SidecarConnectionSettings : ConnectionSettingsBase
{
    /// <summary>
    /// OAuth scopes to request from the sidecar.
    /// Required.
    /// </summary>
    public IList<string> Scopes { get; set; } = new List<string>();

    /// <summary>
    /// Base URL of the Entra ID Agent Container (sidecar).
    /// Optional. If set, provides a fallback URL for the sidecar endpoint.
    /// Resolution order: SIDECAR_URL env var > this setting > default (http://localhost:5000).
    /// </summary>
    public string? SidecarBaseUrl { get; set; }

    /// <summary>
    /// Resolved base URL taking precedence rules into account.
    /// Priority: SIDECAR_URL env var > SidecarBaseUrl config > "http://localhost:5000"
    /// </summary>
    public string ResolvedSidecarBaseUrl =>
        Environment.GetEnvironmentVariable("SIDECAR_URL")
        ?? SidecarBaseUrl
        ?? "http://localhost:5000";

    /// <summary>
    /// HTTP request timeout for sidecar calls. Default: 30 seconds.
    /// </summary>
    public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(30);

    /// <summary>
    /// Number of retry attempts for transient failures. Default: 3.
    /// </summary>
    public int RetryCount { get; set; } = 3;
}
Python

Environment variable style configuration (double-underscore pattern):

CONNECTIONS__SERVICE_CONNECTION__AUTHTYPE=EntraAuthSideCar
CONNECTIONS__SERVICE_CONNECTION__CLIENTID=<blueprint-app-id>
CONNECTIONS__SERVICE_CONNECTION__SCOPES__0=api://<blueprint-app-id>/.default
CONNECTIONS__SERVICE_CONNECTION__SIDECAR_BASE_URL=http://my-sidecar:5000  # optional config fallback
CONNECTIONS_MAP__0__SERVICEURL=*
CONNECTIONS_MAP__0__CONNECTION=SERVICE_CONNECTION
SIDECAR_URL=http://localhost:5000  # takes precedence over SIDECAR_BASE_URL if set

Or programmatic:

from microsoft_agents.hosting.core import AgentAuthConfiguration

config = AgentAuthConfiguration(
    auth_type="EntraAuthSideCar",
    client_id="<blueprint-app-id>",
    scopes=["api://<blueprint-app-id>/.default"],
    sidecar_base_url="http://localhost:5000",  # optional; SIDECAR_URL env var takes precedence
)

manager = SidecarConnectionManager(
    connections_configurations={"SERVICE_CONNECTION": config}
)

New AuthTypes enum value:

class AuthTypes(str, Enum):
    # ... existing values ...
    entra_auth_sidecar = "EntraAuthSideCar"  # NEW — Entra ID Agent Container
TypeScript
// Programmatic
const authConfig: AuthConfiguration = {
  authType: AuthType.EntraAuthSideCar,
  clientId: process.env.BLUEPRINT_APP_ID,
  scope: 'api://<blueprint-app-id>/.default',
  sidecarBaseUrl: 'http://localhost:5000',  // optional; SIDECAR_URL env var takes precedence
  connections: new Map([
    ['serviceConnection', {
      authType: AuthType.EntraAuthSideCar,
      clientId: process.env.BLUEPRINT_APP_ID,
      scope: 'api://<blueprint-app-id>/.default',
      sidecarBaseUrl: 'http://localhost:5000',  // optional; SIDECAR_URL env var takes precedence
    }]
  ]),
  connectionsMap: [{ serviceUrl: '*', connection: 'serviceConnection' }]
}

// Or environment variable style (double-underscore pattern)
// connections__serviceConnection__authType=EntraAuthSideCar
// connections__serviceConnection__clientId=<blueprint-app-id>
// connections__serviceConnection__scope=api://<blueprint-app-id>/.default
// connections__serviceConnection__sidecarBaseUrl=http://my-sidecar:5000  (optional config fallback)
// connectionsMap__0__serviceUrl=*
// connectionsMap__0__connection=serviceConnection
// SIDECAR_URL=http://localhost:5000  (takes precedence over sidecarBaseUrl if set)

New AuthType enum value:

export enum AuthType {
  // ... existing values ...
  EntraAuthSideCar = 'EntraAuthSideCar'  // NEW — Entra ID Agent Container
}

Package Structure

.NET

New NuGet package: Microsoft.Agents.Authentication.EntraAuthSideCar

src/libraries/Authentication/Authentication.EntraAuthSideCar/
├── Microsoft.Agents.Authentication.EntraAuthSideCar.csproj
├── SidecarAuth.cs
├── SidecarTokenClient.cs
├── SidecarTokenCredential.cs
├── SidecarConnectionSettings.cs
├── SidecarServiceCollectionExtensions.cs
└── Model/
    └── SidecarAuthResponse.cs

Dependencies:

  • Microsoft.Agents.Authentication (project ref — core interfaces)
  • Microsoft.Extensions.Http (IHttpClientFactory)
  • Microsoft.Extensions.Http.Polly (retry policies)
Python

New package: microsoft-agents-authentication-entra-sidecar

libraries/microsoft-agents-authentication-entra-sidecar/
├── pyproject.toml
├── setup.py
├── microsoft_agents/
│   └── authentication/
│       └── entra_sidecar/
│           ├── __init__.py
│           ├── sidecar_auth.py
│           ├── sidecar_token_client.py
│           └── errors/
│               ├── __init__.py
│               └── error_resources.py

Additionally, a generic ConnectionManager is added to microsoft-agents-hosting-core:

libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/
├── connection_manager.py   (NEW — generic provider-agnostic connection manager)
└── ...

Note: No sidecar-specific connection manager is needed. The generic ConnectionManager accepts a provider_factory callable, enabling any AccessTokenProviderBase implementation (Sidecar, MSAL, future providers) to be used without duplicating routing logic.

Dependencies:

  • microsoft-agents-hosting-core (core interfaces + generic ConnectionManager)
  • httpx>=0.27.0 (async HTTP client)
TypeScript

New files within existing @microsoft/agents-hosting package (no separate package):

packages/agents-hosting/src/auth/
├── connectionManager.ts        (NEW — generic provider-agnostic connection manager)
├── sidecarAuthProvider.ts      (NEW)
├── msalConnectionManager.ts    (existing — refactored to extend ConnectionManager)
└── index.ts                    (updated — add exports)

Note: No sidecarConnectionManager.ts is needed. Use the generic ConnectionManager with a SidecarAuthProvider factory.

No new npm dependencies — uses native fetch (Node.js 20+ built-in). Existing @azure/msal-node is used for Instance/User token exchange.


Error Handling

Sidecar Response SDK Behavior
200 OK + valid JSON Parse authorizationHeader, strip "Bearer " prefix, return token
401 Unauthorized Throw auth error — sidecar credentials misconfigured
404 Not Found Throw config error — resource not configured on sidecar
400 Bad Request Throw argument error with sidecar error body (e.g., missing AgentIdentity)
500 Internal Server Error Retry per policy; throw auth error after exhausting retries
Connection refused / timeout Retry per policy; throw unavailable error
Non-JSON response Throw auth error with raw response for diagnostics

Token Caching Strategy

Concern Strategy
Sidecar-side caching The sidecar itself caches tokens internally; repeated calls may return cached tokens
SDK-side caching Use the existing SDK token expiration management process (same as MSAL provider)
forceRefresh support Bypass SDK cache; the sidecar will still use its internal cache unless expired

Security Considerations

  1. Network isolation — The sidecar MUST only be accessible from the agent container (same pod in K8s, same Docker bridge network). The SDK MUST validate that the resolved Entra Auth SideCar Base URL points to a loopback address (e.g., localhost, 127.0.0.1, [::1]) or a private network address (RFC 1918 / RFC 4193). If the resolved URL is a non-loopback, non-private address, the provider MUST raise an error and refuse to issue requests. Exception: This validation MUST be skipped if the SidecarBaseUrl is explicitly set in the configuration section of the EntraAuthSideCar entry — an explicit configuration value signals intentional operator override (e.g., for sidecar deployed as a separate container with a routable address within a private network).

  2. No credentials in agent config — Unlike the MSAL providers, this provider does NOT require secrets, certificates, or credential configuration.

  3. Token in transit — Communication between SDK and sidecar is over HTTP (not HTTPS) within the pod boundary. Acceptable because traffic never leaves the pod/node network namespace.

  4. Response validation — Validate that authorizationHeader is non-empty and starts with Bearer before stripping the prefix.


Sidecar Configuration Reference (for container setup — not SDK config)

Variable Required Description
AzureAd__Instance Yes https://login.microsoftonline.com/
AzureAd__TenantId Yes Tenant ID
AzureAd__ClientId Yes Blueprint app registration client ID
AzureAd__ClientCredentials__0__SourceType Yes Credential type (ClientSecret, SignedAssertionFilePath, etc.)
AzureAd__ClientCredentials__0__ClientSecret Dev only Client secret value
DownstreamApis__{name}__BaseUrl Per API Base URL of downstream API (sidecar-side config only)
DownstreamApis__{name}__Scopes__0 Per API OAuth scope for downstream API (sidecar-side config only)
DownstreamApis__{name}__RequestAppToken Per API true for app-only tokens (sidecar-side config only)

Testing Strategy

Test Type Description
Unit tests Mock HTTP client to simulate sidecar responses (200, 401, 404, 500, timeout)
Integration tests Docker Compose with actual sidecar container + test Entra tenant
Health check tests Verify startup behavior when sidecar is unavailable
E2E tests Full agent scenario: SDK → Sidecar → Entra ID → downstream API

Open Questions

# Question Impact
1 Should the SDK-side cache read the JWT exp claim, or rely solely on a configured TTL? Resolved: Use the existing SDK token expiration management process (same as MSAL provider).
2 Should Instance/User token methods also delegate to the sidecar, or use the existing SDK path? Resolved: Use the existing SDK built-in system. Can be revisited in a future phase.
3 What is the startup behavior if /healthz fails? Fail-fast (throw) or deferred-failure (fail on first token request)? Developer experience
4 Does the sidecar return token expiry metadata or should the SDK always parse the JWT? Token caching implementation
5 (TypeScript) Should this be a separate npm package or new files in the existing @microsoft/agents-hosting? Package architecture

References

Metadata

Metadata

Labels

No labels
No labels
No fields configured for Feature.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions