Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions src/Runtime/Core/Utility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,18 @@ public static HttpClient GetDefaultHttpClient(IHttpClientFactory httpClientFacto
}

/// <summary>
/// Decodes the current token and retrieves the App ID (appid or azp claim).
/// <para><b>WARNING: NO SIGNATURE VERIFICATION</b> - This method uses JwtSecurityTokenHandler.ReadJwtToken()
/// which does NOT verify the token signature. The token claims can be spoofed by malicious actors.</para>
/// <para>This method is ONLY suitable for logging, analytics, and diagnostics purposes.
/// Do NOT use the returned value for authorization, access control, or security decisions.</para>
/// <para>Decodes the current token and retrieves the App ID (appid or azp claim).</para>
/// <para>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 <see cref="GetAgentIdFromToken"/>.</para>
/// </summary>
/// <param name="token">Token to Decode</param>
/// <returns>AppId</returns>
/// <returns>AppId, or default GUID for empty token</returns>
/// <exception cref="ArgumentException">Thrown when token format is invalid</exception>
public static string GetAppIdFromToken(string token)
{
if (string.IsNullOrWhiteSpace(token))
Expand All @@ -70,6 +78,62 @@ public static string GetAppIdFromToken(string token)
return appIdClaim?.Value ?? string.Empty;
}

/// <summary>
/// <para><b>WARNING: NO SIGNATURE VERIFICATION</b> - This method uses JwtSecurityTokenHandler.ReadJwtToken()
/// which does NOT verify the token signature. The token claims can be spoofed by malicious actors.</para>
/// <para>This method is ONLY suitable for logging, analytics, and diagnostics purposes.
/// Do NOT use the returned value for authorization, access control, or security decisions.</para>
/// <para>Decodes the token and retrieves the best available agent identifier.
/// Checks claims in priority order: xms_par_app_azp (agent blueprint ID) > appid > azp.</para>
/// <para>Note: Returns empty string for empty/missing tokens (unlike <see cref="GetAppIdFromToken"/> which
/// returns a default GUID). This allows callers to omit headers when no identifier is available.</para>
/// </summary>
/// <param name="token">JWT token to decode</param>
/// <returns>Agent ID (GUID) or empty string if not found or token is empty</returns>
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;
}
}

/// <summary>
/// Gets the application name from the entry assembly.
/// </summary>
/// <returns>Application name or null if not available.</returns>
public static string? GetApplicationName()
{
return System.Reflection.Assembly.GetEntryAssembly()?.GetName()?.Name;
}

/// <summary>
/// Resolves the agent identity from the turn context or auth token.
/// </summary>
Expand Down
187 changes: 187 additions & 0 deletions src/Tests/Runtime.Tests/UtilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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

/// <summary>
/// Creates a test JWT token with the specified claims.
/// Note: This creates an unsigned token suitable for testing claim extraction only.
/// </summary>
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
}
}
69 changes: 68 additions & 1 deletion src/Tooling/Core/Handlers/HttpContextHeadersHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using RuntimeUtility = Microsoft.Agents.A365.Runtime.Utils.Utility;

internal class HttpContextHeadersHandler : DelegatingHandler
{
Expand All @@ -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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -86,9 +90,72 @@ protected override Task<HttpResponseMessage> 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);
}

/// <summary>
/// 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
/// </summary>
/// <returns>Agent ID string or null if not available.</returns>
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();
}

/// <summary>
/// Gets the agentic app blueprint ID from the turn context if available.
/// </summary>
/// <returns>The blueprint ID or null if not available.</returns>
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
Expand Down
3 changes: 3 additions & 0 deletions src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
<Product>Microsoft Agent 365 Tooling SDK</Product>
<PackageTags>Microsoft;Agent365;A365;Tooling;MCP;AI;Agents</PackageTags>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.A365.Tooling.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.SemanticKernel.Agents.Core" />
Expand Down
Loading
Loading