diff --git a/src/Runtime/Core/Utility.cs b/src/Runtime/Core/Utility.cs index 7354d04e..cdf7455b 100644 --- a/src/Runtime/Core/Utility.cs +++ b/src/Runtime/Core/Utility.cs @@ -54,10 +54,18 @@ public static HttpClient GetDefaultHttpClient(IHttpClientFactory httpClientFacto } /// - /// Decodes the current token and retrieves the App ID (appid or azp claim). + /// WARNING: NO SIGNATURE VERIFICATION - This method uses JwtSecurityTokenHandler.ReadJwtToken() + /// which does NOT verify the token signature. The token claims can be spoofed by malicious actors. + /// This method is ONLY suitable for logging, analytics, and diagnostics purposes. + /// Do NOT use the returned value for authorization, access control, or security decisions. + /// Decodes the current token and retrieves the App ID (appid or azp claim). + /// Note: Returns a default GUID ('00000000-0000-0000-0000-000000000000') for empty tokens + /// for backward compatibility with callers that expect a valid-looking GUID. + /// For agent identification where empty string is preferred, use . /// /// Token to Decode - /// AppId + /// AppId, or default GUID for empty token + /// Thrown when token format is invalid public static string GetAppIdFromToken(string token) { if (string.IsNullOrWhiteSpace(token)) @@ -70,6 +78,62 @@ public static string GetAppIdFromToken(string token) return appIdClaim?.Value ?? string.Empty; } + /// + /// WARNING: NO SIGNATURE VERIFICATION - This method uses JwtSecurityTokenHandler.ReadJwtToken() + /// which does NOT verify the token signature. The token claims can be spoofed by malicious actors. + /// This method is ONLY suitable for logging, analytics, and diagnostics purposes. + /// Do NOT use the returned value for authorization, access control, or security decisions. + /// Decodes the token and retrieves the best available agent identifier. + /// Checks claims in priority order: xms_par_app_azp (agent blueprint ID) > appid > azp. + /// Note: Returns empty string for empty/missing tokens (unlike which + /// returns a default GUID). This allows callers to omit headers when no identifier is available. + /// + /// JWT token to decode + /// Agent ID (GUID) or empty string if not found or token is empty + public static string GetAgentIdFromToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return string.Empty; + } + + try + { + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + + // Priority: xms_par_app_azp (agent blueprint ID) > appid > azp + var blueprintClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "xms_par_app_azp"); + if (!string.IsNullOrEmpty(blueprintClaim?.Value)) + { + return blueprintClaim.Value; + } + + var appIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "appid"); + if (!string.IsNullOrEmpty(appIdClaim?.Value)) + { + return appIdClaim.Value; + } + + var azpClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "azp"); + return azpClaim?.Value ?? string.Empty; + } + catch + { + // Silent error handling - return empty string on decode failure + return string.Empty; + } + } + + /// + /// Gets the application name from the entry assembly. + /// + /// Application name or null if not available. + public static string? GetApplicationName() + { + return System.Reflection.Assembly.GetEntryAssembly()?.GetName()?.Name; + } + /// /// Resolves the agent identity from the turn context or auth token. /// diff --git a/src/Tests/Runtime.Tests/UtilityTests.cs b/src/Tests/Runtime.Tests/UtilityTests.cs index ef9cace0..a66e91d7 100644 --- a/src/Tests/Runtime.Tests/UtilityTests.cs +++ b/src/Tests/Runtime.Tests/UtilityTests.cs @@ -4,6 +4,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Agents.A365.Runtime.Utils; using Moq; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; namespace Microsoft.Agents.A365.Runtime.Tests { @@ -84,5 +87,189 @@ public void GetCurrentEnvironment_ReturnsDevelopment_WhenConfigMissing() // Assert Assert.Equal("Development", result); } + + #region GetAgentIdFromToken Tests + + [Fact] + public void GetAgentIdFromToken_ReturnsEmptyString_WhenTokenIsEmpty() + { + // Act + var result = Utility.GetAgentIdFromToken(""); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetAgentIdFromToken_ReturnsEmptyString_WhenTokenIsWhitespace() + { + // Act + var result = Utility.GetAgentIdFromToken(" "); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetAgentIdFromToken_ReturnsEmptyString_WhenTokenIsNull() + { + // Act + var result = Utility.GetAgentIdFromToken(null!); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetAgentIdFromToken_ReturnsBlueprintId_WhenXmsParAppAzpPresent() + { + // Arrange + var token = CreateTestJwtToken(new Claim("xms_par_app_azp", "blueprint-id-123"), + new Claim("appid", "app-id-456"), + new Claim("azp", "azp-id-789")); + + // Act + var result = Utility.GetAgentIdFromToken(token); + + // Assert + Assert.Equal("blueprint-id-123", result); + } + + [Fact] + public void GetAgentIdFromToken_ReturnsAppId_WhenXmsParAppAzpNotPresent() + { + // Arrange + var token = CreateTestJwtToken(new Claim("appid", "app-id-456"), + new Claim("azp", "azp-id-789")); + + // Act + var result = Utility.GetAgentIdFromToken(token); + + // Assert + Assert.Equal("app-id-456", result); + } + + [Fact] + public void GetAgentIdFromToken_ReturnsAzp_WhenOnlyAzpPresent() + { + // Arrange + var token = CreateTestJwtToken(new Claim("azp", "azp-id-789")); + + // Act + var result = Utility.GetAgentIdFromToken(token); + + // Assert + Assert.Equal("azp-id-789", result); + } + + [Fact] + public void GetAgentIdFromToken_ReturnsEmptyString_WhenNoRelevantClaimsPresent() + { + // Arrange + var token = CreateTestJwtToken(new Claim("sub", "some-subject"), + new Claim("iss", "some-issuer")); + + // Act + var result = Utility.GetAgentIdFromToken(token); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetAgentIdFromToken_ReturnsEmptyString_WhenTokenIsMalformed() + { + // Act + var result = Utility.GetAgentIdFromToken("not-a-valid-jwt-token"); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetAgentIdFromToken_PrefersXmsParAppAzp_OverAppId() + { + // Arrange - both present, xms_par_app_azp should win + var token = CreateTestJwtToken(new Claim("appid", "app-id-first"), + new Claim("xms_par_app_azp", "blueprint-id-second")); + + // Act + var result = Utility.GetAgentIdFromToken(token); + + // Assert + Assert.Equal("blueprint-id-second", result); + } + + [Fact] + public void GetAgentIdFromToken_FallsBackToAppId_WhenXmsParAppAzpIsEmpty() + { + // Arrange + var token = CreateTestJwtToken(new Claim("xms_par_app_azp", ""), + new Claim("appid", "app-id-456")); + + // Act + var result = Utility.GetAgentIdFromToken(token); + + // Assert + Assert.Equal("app-id-456", result); + } + + [Fact] + public void GetAgentIdFromToken_FallsBackToAzp_WhenBothXmsParAppAzpAndAppIdAreEmpty() + { + // Arrange + var token = CreateTestJwtToken(new Claim("xms_par_app_azp", ""), + new Claim("appid", ""), + new Claim("azp", "azp-id-789")); + + // Act + var result = Utility.GetAgentIdFromToken(token); + + // Assert + Assert.Equal("azp-id-789", result); + } + + #endregion + + #region GetApplicationName Tests + + [Fact] + public void GetApplicationName_ReturnsAssemblyName() + { + // Act + var result = Utility.GetApplicationName(); + + // Assert + // In a test context, the entry assembly should exist and have a name + // The exact name depends on the test runner, so we just verify it's not null + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a test JWT token with the specified claims. + /// Note: This creates an unsigned token suitable for testing claim extraction only. + /// + private static string CreateTestJwtToken(params Claim[] claims) + { + var header = Convert.ToBase64String(Encoding.UTF8.GetBytes("{\"alg\":\"none\",\"typ\":\"JWT\"}")) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + var payloadDict = claims + .GroupBy(c => c.Type) + .ToDictionary(g => g.Key, g => g.First().Value); + + var payloadJson = System.Text.Json.JsonSerializer.Serialize(payloadDict); + var payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(payloadJson)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + return $"{header}.{payload}."; + } + + #endregion } } diff --git a/src/Tooling/Core/Handlers/HttpContextHeadersHandler.cs b/src/Tooling/Core/Handlers/HttpContextHeadersHandler.cs index 38654f0d..749b0d5d 100644 --- a/src/Tooling/Core/Handlers/HttpContextHeadersHandler.cs +++ b/src/Tooling/Core/Handlers/HttpContextHeadersHandler.cs @@ -6,6 +6,7 @@ namespace Microsoft.Agents.A365.Tooling.Handlers using Microsoft.Extensions.Logging; using Microsoft.Agents.A365.Runtime; using Microsoft.Agents.A365.Tooling.Models; + using Microsoft.Agents.A365.Tooling.Utils; using System; using System.Globalization; using System.Net.Http; @@ -13,6 +14,7 @@ namespace Microsoft.Agents.A365.Tooling.Handlers using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; + using RuntimeUtility = Microsoft.Agents.A365.Runtime.Utils.Utility; internal class HttpContextHeadersHandler : DelegatingHandler { @@ -32,12 +34,14 @@ internal class HttpContextHeadersHandler : DelegatingHandler private readonly ITurnContext turnContext; private readonly ILogger logger; private readonly ToolOptions toolOptions; + private readonly string? authToken; - public HttpContextHeadersHandler(ITurnContext turnContext, ILogger logger, ToolOptions toolOptions) + public HttpContextHeadersHandler(ITurnContext turnContext, ILogger logger, ToolOptions toolOptions, string? authToken = null) { this.turnContext = turnContext; this.logger = logger; this.toolOptions = toolOptions; + this.authToken = authToken; } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) @@ -86,9 +90,72 @@ protected override Task SendAsync(HttpRequestMessage reques request.Headers.Add(UserAgentHeader, UserAgentHelper.BuildUserAgent(this.toolOptions.UserAgentConfiguration)); } + // Add x-ms-agentid header if auth token is available + if (!string.IsNullOrEmpty(authToken)) + { + var agentId = ResolveAgentIdForHeader(); + if (!string.IsNullOrEmpty(agentId)) + { + request.Headers.Add(Constants.Headers.AgentIdHeader, agentId); + } + } + return base.SendAsync(request, cancellationToken); } + /// + /// Resolves the best available agent identifier for the x-ms-agentid header. + /// Priority: TurnContext.agenticAppBlueprintId > token claims (xms_par_app_azp > appid > azp) > application name + /// + /// Agent ID string or null if not available. + private string? ResolveAgentIdForHeader() + { + // Priority 1: Agent Blueprint ID from TurnContext + // The 'From' property may include agenticAppBlueprintId when the request originates from an agentic app + var blueprintId = GetAgenticAppBlueprintIdFromContext(); + if (!string.IsNullOrEmpty(blueprintId)) + { + return blueprintId; + } + + // Priority 2 & 3: Agent ID from token (xms_par_app_azp > appid > azp) + // Single decode, checks claims in priority order + if (!string.IsNullOrEmpty(authToken)) + { + var agentId = RuntimeUtility.GetAgentIdFromToken(authToken); + if (!string.IsNullOrEmpty(agentId)) + { + return agentId; + } + } + + // Priority 4: Application name from assembly + return RuntimeUtility.GetApplicationName(); + } + + /// + /// Gets the agentic app blueprint ID from the turn context if available. + /// + /// The blueprint ID or null if not available. + private string? GetAgenticAppBlueprintIdFromContext() + { + if (turnContext?.Activity?.From?.Properties == null) + { + return null; + } + + if (turnContext.Activity.From.Properties.TryGetValue("agenticAppBlueprintId", out var blueprintIdElement)) + { + var blueprintId = blueprintIdElement.ToString(); + if (!string.IsNullOrEmpty(blueprintId)) + { + return blueprintId; + } + } + + return null; + } + public static string SanitizeTextForHeader(string input, ILogger logger) { try diff --git a/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj b/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj index 7e3d4104..267e06c2 100644 --- a/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj +++ b/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj @@ -12,6 +12,9 @@ Microsoft Agent 365 Tooling SDK Microsoft;Agent365;A365;Tooling;MCP;AI;Agents + + + diff --git a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs index 93d6bb98..db26decb 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -482,7 +482,7 @@ private async Task CreateMcpClientWithAuthHandlers(ITurnContext turn this._logger.LogInformation($"Configured authentication handler for MCP endpoint {endpoint}"); - var httpContextHeaderHandler = new HttpContextHeadersHandler(turnContext, this._logger, toolOptions) + var httpContextHeaderHandler = new HttpContextHeadersHandler(turnContext, this._logger, toolOptions, authToken) { InnerHandler = authHandler, }; diff --git a/src/Tooling/Core/Utils/Constants.cs b/src/Tooling/Core/Utils/Constants.cs index 21fc2c2e..a3bff780 100644 --- a/src/Tooling/Core/Utils/Constants.cs +++ b/src/Tooling/Core/Utils/Constants.cs @@ -1,5 +1,5 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. namespace Microsoft.Agents.A365.Tooling.Utils { @@ -17,6 +17,11 @@ public class Headers /// The prefix used for Bearer authentication tokens in HTTP headers. /// public const string BearerPrefix = "Bearer"; + + /// + /// Header name for sending the agent identifier to MCP platform for logging/analytics. + /// + public const string AgentIdHeader = "x-ms-agentid"; } } } diff --git a/src/Tooling/Core/docs/prd-x-ms-agentid-header.md b/src/Tooling/Core/docs/prd-x-ms-agentid-header.md new file mode 100644 index 00000000..2f31d621 --- /dev/null +++ b/src/Tooling/Core/docs/prd-x-ms-agentid-header.md @@ -0,0 +1,271 @@ +# PRD: x-ms-agentId Header for MCP Platform Calls + +## Overview + +Add an `x-ms-agentId` header to all outbound HTTP requests from the tooling package to the MCP platform. This header identifies the calling agent using the best available identifier. + +## Problem Statement + +The MCP platform needs to identify which agent is making tooling requests for: +- Logging and diagnostics +- Usage analytics + +Currently, no consistent agent identifier is sent with MCP platform requests. + +## Requirements + +### Functional Requirements + +1. All HTTP requests to the MCP platform SHALL include the `x-ms-agentId` header +2. The header value SHALL be determined using the following priority: + 1. **Agent Blueprint ID from TurnContext** (highest priority) - from `TurnContext.Activity.From.AgenticAppBlueprintId` + 2. **Agent Blueprint ID from token** (`xms_par_app_azp` claim) + 3. **Entra Application ID from token** (`appid` or `azp` claim) + 4. **Application name** (lowest priority fallback) - from assembly name +3. If no identifier is available, the header SHOULD be omitted (not sent with empty value) + +### Non-Functional Requirements + +1. No additional network calls to retrieve identifiers +2. Minimal performance impact on existing flows +3. Backward compatible - existing integrations continue to work + +## Technical Design + +### Affected Components + +| Package | File | Change | +|---------|------|--------| +| `Microsoft.Agents.A365.Runtime` | `Utility.cs` | Add `GetAgentIdFromToken()` method (checks `xms_par_app_azp` → `appid` → `azp`) | +| `Microsoft.Agents.A365.Runtime` | `Utility.cs` | Add `GetApplicationName()` method (returns assembly name) | +| `Microsoft.Agents.A365.Tooling` | `Constants.cs` | Add `AgentIdHeader` constant | +| `Microsoft.Agents.A365.Tooling` | `HttpContextHeadersHandler.cs` | Add `x-ms-agentid` header with priority fallback | + +### Identifier Retrieval Strategy + +#### 1. Agent Blueprint ID from TurnContext (Highest Priority) + +**Source**: `TurnContext.Activity.From.Properties["agenticAppBlueprintId"]` + +**Availability**: Only available in agentic request scenarios where a `TurnContext` is present and the request originates from another agent. + +**Format**: GUID (e.g., `12345678-1234-1234-1234-123456789abc`) + +--- + +#### 2 & 3. Agent ID from Token (Second/Third Priority) + +**Sources** (checked in order): +1. `xms_par_app_azp` claim - Agent Blueprint ID (parent application's Azure app ID) +2. `appid` or `azp` claim - Entra Application ID + +**Availability**: Available when an `authToken` is provided to the tooling methods. + +**Retrieval**: New utility function that decodes token once and checks claims in priority order: + +```csharp +// Microsoft.Agents.A365.Runtime.Utils.Utility +public static string GetAgentIdFromToken(string token) +{ + if (string.IsNullOrWhiteSpace(token)) + { + return string.Empty; + } + + try + { + var handler = new JwtSecurityTokenHandler(); + var jwtToken = handler.ReadJwtToken(token); + + // Priority: xms_par_app_azp (blueprint ID) > appid > azp + var blueprintClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "xms_par_app_azp"); + if (!string.IsNullOrEmpty(blueprintClaim?.Value)) + { + return blueprintClaim.Value; + } + + var appIdClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "appid"); + if (!string.IsNullOrEmpty(appIdClaim?.Value)) + { + return appIdClaim.Value; + } + + var azpClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "azp"); + return azpClaim?.Value ?? string.Empty; + } + catch + { + return string.Empty; + } +} +``` + +**Format**: GUID (e.g., `12345678-1234-1234-1234-123456789abc`) + +--- + +#### 4. Application Name (Lowest Priority Fallback) + +**Source**: Entry assembly name + +**Strategy**: +1. Get the entry assembly name via `Assembly.GetEntryAssembly()?.GetName()?.Name` +2. If not available, omit the header + +**Implementation**: +```csharp +// Microsoft.Agents.A365.Runtime.Utils.Utility +public static string? GetApplicationName() +{ + return Assembly.GetEntryAssembly()?.GetName()?.Name; +} +``` + +--- + +### Implementation + +#### Updated HttpContextHeadersHandler + +The `x-ms-agentid` header will be added in the `HttpContextHeadersHandler` which already handles other context headers. The header is only added when the auth token is available (via the `BearerTokenHandler` in the chain). + +```csharp +// Microsoft.Agents.A365.Tooling.Handlers.HttpContextHeadersHandler +internal class HttpContextHeadersHandler : DelegatingHandler +{ + private const string AgentIdHeader = "x-ms-agentid"; + // ... existing headers ... + + private readonly string? authToken; + + public HttpContextHeadersHandler(ITurnContext turnContext, ILogger logger, ToolOptions toolOptions, string? authToken = null) + { + this.turnContext = turnContext; + this.logger = logger; + this.toolOptions = toolOptions; + this.authToken = authToken; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + // ... existing header logic ... + + // Add x-ms-agentid header if auth token is available + if (!string.IsNullOrEmpty(authToken)) + { + var agentId = ResolveAgentIdForHeader(); + if (!string.IsNullOrEmpty(agentId)) + { + request.Headers.Add(AgentIdHeader, agentId); + } + } + + return base.SendAsync(request, cancellationToken); + } + + private string? ResolveAgentIdForHeader() + { + // Priority 1: Agent Blueprint ID from TurnContext + var blueprintId = GetAgenticAppBlueprintIdFromContext(); + if (!string.IsNullOrEmpty(blueprintId)) + { + return blueprintId; + } + + // Priority 2 & 3: Agent ID from token (xms_par_app_azp > appid > azp) + if (!string.IsNullOrEmpty(authToken)) + { + var agentId = RuntimeUtility.GetAgentIdFromToken(authToken); + if (!string.IsNullOrEmpty(agentId)) + { + return agentId; + } + } + + // Priority 4: Application name from assembly + return RuntimeUtility.GetApplicationName(); + } +} +``` + +### Call Sites Summary + +| Call Site | authToken | turnContext | Gets `x-ms-agentid`? | +|-----------|-----------|-------------|----------------------| +| `GetMCPServerFromToolingGatewayAsync()` | ✅ | ❌ (not passed) | ✅ Yes (via new overload) | +| `GetMcpClientToolsAsync()` | ✅ | ✅ | ✅ Yes | +| `SendChatHistoryAsync()` | ❌ | ✅ | ❌ No (authToken required) | + +**Note**: The `x-ms-agentid` header is only added when `authToken` is present. `SendChatHistoryAsync()` does not use authentication, so it won't include this header. + +--- + +## Open Questions + +### Q1: Application Name Strategy ✅ RESOLVED + +**Decision**: Use `Assembly.GetEntryAssembly()?.GetName()?.Name` as the .NET equivalent of the Node.js npm_package_name. + +### Q2: Header Name Casing ✅ RESOLVED + +**Decision**: Use `x-ms-agentid` (all lowercase, case insensitive). + +HTTP headers are case-insensitive per RFC 7230, so the server will accept any casing. Using lowercase is the conventional choice. + +### Q3: Missing Identifier Behavior ✅ RESOLVED + +**Decision**: Omit the header entirely if no identifier is available. Do not send empty or "unknown" values. + +--- + +## Testing Strategy + +### Unit Tests + +1. Test `GetAgentIdFromToken()` checks claims in correct priority order (`xms_par_app_azp` > `appid` > `azp`) +2. Test `GetAgentIdFromToken()` returns empty string for empty/invalid tokens +3. Test `GetApplicationName()` returns assembly name +4. Test `HttpContextHeadersHandler` includes `x-ms-agentid` when identifier available +5. Test `HttpContextHeadersHandler` omits header when no identifier available +6. Test priority order: TurnContext > token claims > application name + +### Integration Tests + +1. Verify header is sent in `GetMcpClientToolsAsync()` requests +2. Verify header is NOT sent in `SendChatHistoryAsync()` requests (no authToken) + +--- + +## Breaking Changes + +**None** - This implementation is fully backward compatible. + +### Migration Guide + +**For existing consumers:** +- No changes required - existing code continues to work +- The `x-ms-agentid` header will automatically be included in requests where authentication is used + +--- + +## Rollout Plan + +1. **Phase 1**: Add utility methods to Runtime package +2. **Phase 2**: Update `HttpContextHeadersHandler` to add `x-ms-agentid` header +3. **Phase 3**: Update documentation + +--- + +## Dependencies + +- Runtime package for `GetAppIdFromToken()` utility (already exists) +- `System.IdentityModel.Tokens.Jwt` for JWT decoding (already referenced) +- No new external dependencies required + +--- + +## Success Metrics + +1. 100% of MCP platform requests include `x-ms-agentId` header (when identifier available) +2. No increase in request latency +3. No breaking changes for existing consumers