diff --git a/src/Microsoft.Teams.Core/BotApplication.cs b/src/Microsoft.Teams.Core/BotApplication.cs
index afc5f2a8..2e7aab2e 100644
--- a/src/Microsoft.Teams.Core/BotApplication.cs
+++ b/src/Microsoft.Teams.Core/BotApplication.cs
@@ -179,6 +179,9 @@ public BotApplication(ConversationClient conversationClient, UserTokenClient use
/// default 5 minutes) is used instead of the HTTP request's cancellation token, because streaming handlers
/// may outlive the original HTTP connection. When a debugger is attached the timeout is disabled.
///
+ ///
+ /// Entra tokens not requested by the Agent 365 platform are rejected with 401 Unauthorized.
+ ///
///
/// The HTTP context containing the incoming bot activity request.
/// A cancellation token that can be used to cancel the initial deserialization. Note: a dedicated timeout governs activity processing.
@@ -199,6 +202,13 @@ public virtual async Task ProcessAsync(HttpContext httpContext, CancellationToke
_logger.StartProcessingActivity();
+ if (httpContext.Items.TryGetValue(JwtExtensions.EntraCallerAppNotAllowedKey, out object? callerAppId))
+ {
+ _logger.EntraCallerAppNotAllowed(callerAppId as string ?? string.Empty);
+ httpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return;
+ }
+
CoreActivity activity = await CoreActivity.FromJsonStreamAsync(httpContext.Request.Body, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Invalid Activity");
await ProcessAsync(
diff --git a/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
index c7829516..a83b73eb 100644
--- a/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
+++ b/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
@@ -24,6 +24,18 @@ namespace Microsoft.Teams.Core.Hosting
///
public static class JwtExtensions
{
+ ///
+ /// App ID of the Agent 365 platform.
+ ///
+ internal const string Agent365PlatformAppId = "5a807f24-c9de-44ee-a3a7-329e88a00ffc";
+
+ ///
+ /// key set when an Entra token validated, but its caller app is not .
+ /// The value is the caller app ID, or an empty string when the token carries none.
+ /// Read by .
+ ///
+ internal static readonly object EntraCallerAppNotAllowedKey = new();
+
///
/// Adds JWT authentication for bots and agents using configuration from appsettings.
///
@@ -204,6 +216,27 @@ internal static string ResolveSigningAuthority(string? iss, string? tid, string
: $"{entraInstance}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration";
}
+ ///
+ /// Returns the client app that requested an Entra token when it is not , or when it is allowed or the token is a Bot Framework token.
+ /// Entra v2 tokens carry the caller in azp and v1 tokens carry it in appid.
+ /// appid is only consulted when azp is absent, so a present but invalid azp is rejected.
+ ///
+ internal static string? GetDisallowedEntraCallerApp(JsonWebToken token, string botTokenIssuer)
+ {
+ if (token.Issuer.Equals(botTokenIssuer, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ string? callerAppId = token.TryGetClaim("azp", out Claim? azp)
+ ? azp.Value
+ : token.TryGetPayloadValue("appid", out string? appid) ? appid : null;
+
+ return string.Equals(callerAppId, Agent365PlatformAppId, StringComparison.OrdinalIgnoreCase)
+ ? null
+ : callerAppId ?? string.Empty;
+ }
+
private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) =>
token is JsonWebToken jwt
? (jwt.Issuer, jwt.TryGetClaim("tid", out Claim? c) ? c.Value : null)
@@ -345,10 +378,19 @@ private static AuthenticationBuilder AddTeamsJwtBearer(
{
ILogger log = GetLogger(context.HttpContext, logger);
log.TokenValidated(schemeName);
- if (log.IsEnabled(LogLevel.Trace) && context.SecurityToken is JsonWebToken jwt)
+ if (context.SecurityToken is JsonWebToken jwt)
{
- string claims = Environment.NewLine + string.Join(Environment.NewLine, jwt.Claims.Select(c => $" {c.Type}: {c.Value}"));
- log.IncomingTokenClaims(claims);
+ if (log.IsEnabled(LogLevel.Trace))
+ {
+ string claims = Environment.NewLine + string.Join(Environment.NewLine, jwt.Claims.Select(c => $" {c.Type}: {c.Value}"));
+ log.IncomingTokenClaims(claims);
+ }
+
+ string? disallowedCallerApp = GetDisallowedEntraCallerApp(jwt, botTokenIssuer);
+ if (disallowedCallerApp is not null)
+ {
+ context.HttpContext.Items[EntraCallerAppNotAllowedKey] = disallowedCallerApp;
+ }
}
return Task.CompletedTask;
},
diff --git a/src/Microsoft.Teams.Core/Log.cs b/src/Microsoft.Teams.Core/Log.cs
index 23fd0b8d..1391d4fc 100644
--- a/src/Microsoft.Teams.Core/Log.cs
+++ b/src/Microsoft.Teams.Core/Log.cs
@@ -43,6 +43,9 @@ internal static partial class Log
[LoggerMessage(EventId = 8, Level = LogLevel.Debug, Message = "ServiceUrl in activity ({ActivityServiceUrl}) does not match serviceUrl claim ({ClaimServiceUrl}).")]
public static partial void LogServiceUrlClaimMismatch(this ILogger logger, Uri? activityServiceUrl, string claimServiceUrl);
+ [LoggerMessage(EventId = 9, Level = LogLevel.Warning, Message = "Rejecting activity: Entra inbound token caller app '{CallerAppId}' is not allowed.")]
+ public static partial void EntraCallerAppNotAllowed(this ILogger logger, string callerAppId);
+
// ── ConversationClient ──────────────────────────────────────────────
[LoggerMessage(EventId = 11, Level = LogLevel.Trace, Message = "Updating activity at {Url}: {Activity}")]
diff --git a/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs b/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs
index 4e7a3ea2..ff7af228 100644
--- a/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs
+++ b/test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs
@@ -458,6 +458,43 @@ public async Task ProcessAsync_HandlerThrowsTimeoutException_ThrowsBotHandlerExc
Assert.Same(activity, exception.Activity);
}
+ [Fact]
+ public async Task ProcessAsync_EntraCallerAppNotAllowed_Returns401WithoutProcessing()
+ {
+ BotApplication botApp = CreateBotApplication();
+ bool onActivityCalled = false;
+ botApp.OnActivity = (_, _) =>
+ {
+ onActivityCalled = true;
+ return Task.CompletedTask;
+ };
+ DefaultHttpContext httpContext = CreateHttpContextWithActivity(new CoreActivity(ActivityType.Message) { Id = "act123" });
+ httpContext.Items[JwtExtensions.EntraCallerAppNotAllowedKey] = "22222222-2222-2222-2222-222222222222";
+
+ await botApp.ProcessAsync(httpContext);
+
+ Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode);
+ Assert.False(onActivityCalled);
+ }
+
+ [Fact]
+ public async Task ProcessAsync_EntraCallerAppNotMarked_ProcessesActivity()
+ {
+ BotApplication botApp = CreateBotApplication();
+ bool onActivityCalled = false;
+ botApp.OnActivity = (_, _) =>
+ {
+ onActivityCalled = true;
+ return Task.CompletedTask;
+ };
+ DefaultHttpContext httpContext = CreateHttpContextWithActivity(new CoreActivity(ActivityType.Message) { Id = "act123" });
+
+ await botApp.ProcessAsync(httpContext);
+
+ Assert.NotEqual(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode);
+ Assert.True(onActivityCalled);
+ }
+
private static BotApplicationOptions CreateOptions(string appId) =>
new() { AppId = appId };
diff --git a/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
index 74a669aa..56ec77d5 100644
--- a/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
+++ b/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
@@ -357,4 +358,104 @@ public void AddBotAuthentication_ConfiguresExpectedInboundAudiences()
[ClientId, $"api://{ClientId}", $"api://botid-{ClientId}"],
options.TokenValidationParameters.ValidAudiences);
}
+
+ private const string OtherAppId = "22222222-2222-2222-2222-222222222222";
+ private const string EntraIssuer = $"https://login.microsoftonline.com/{Tenant}/v2.0";
+
+ private static async Task RunOnTokenValidatedAsync(string issuer, Dictionary claims)
+ {
+ ServiceCollection services = new();
+ services.AddLogging();
+ services.AddBotAuthentication(ClientId, Tenant);
+ ServiceProvider provider = services.BuildServiceProvider();
+ JwtBearerOptions options = provider
+ .GetRequiredService>()
+ .Get(BotConfig.DefaultSectionName);
+
+ JsonWebTokenHandler handler = new();
+ SecurityTokenDescriptor descriptor = new() { Issuer = issuer, Claims = claims };
+ DefaultHttpContext httpContext = new() { RequestServices = provider };
+ TokenValidatedContext context = new(
+ httpContext,
+ new AuthenticationScheme(BotConfig.DefaultSectionName, null, typeof(JwtBearerHandler)),
+ options)
+ {
+ Principal = new ClaimsPrincipal(new ClaimsIdentity()),
+ SecurityToken = new JsonWebToken(handler.CreateToken(descriptor)),
+ };
+
+ await options.Events.OnTokenValidated(context);
+ Assert.Null(context.Result);
+ return httpContext;
+ }
+
+ [Fact]
+ public async Task OnTokenValidated_EntraCallerAppInAzp_IsAllowed()
+ {
+ HttpContext httpContext = await RunOnTokenValidatedAsync(EntraIssuer, new() { ["azp"] = JwtExtensions.Agent365PlatformAppId });
+
+ Assert.False(httpContext.Items.ContainsKey(JwtExtensions.EntraCallerAppNotAllowedKey));
+ }
+
+ [Fact]
+ public async Task OnTokenValidated_EntraCallerAppInAppIdWithoutAzp_IsAllowed()
+ {
+ HttpContext httpContext = await RunOnTokenValidatedAsync(
+ $"https://sts.windows.net/{Tenant}/",
+ new() { ["appid"] = JwtExtensions.Agent365PlatformAppId.ToUpperInvariant() });
+
+ Assert.False(httpContext.Items.ContainsKey(JwtExtensions.EntraCallerAppNotAllowedKey));
+ }
+
+ [Fact]
+ public async Task OnTokenValidated_EntraOtherCallerAppInAzp_IsMarkedNotAllowed()
+ {
+ HttpContext httpContext = await RunOnTokenValidatedAsync(EntraIssuer, new() { ["azp"] = OtherAppId });
+
+ Assert.Equal(OtherAppId, httpContext.Items[JwtExtensions.EntraCallerAppNotAllowedKey]);
+ }
+
+ [Fact]
+ public async Task OnTokenValidated_EntraOtherCallerAppInAppId_IsMarkedNotAllowed()
+ {
+ HttpContext httpContext = await RunOnTokenValidatedAsync(EntraIssuer, new() { ["appid"] = OtherAppId });
+
+ Assert.Equal(OtherAppId, httpContext.Items[JwtExtensions.EntraCallerAppNotAllowedKey]);
+ }
+
+ [Fact]
+ public async Task OnTokenValidated_EntraWithoutCallerAppClaims_IsMarkedNotAllowed()
+ {
+ HttpContext httpContext = await RunOnTokenValidatedAsync(EntraIssuer, new() { ["tid"] = Tenant });
+
+ Assert.Equal(string.Empty, httpContext.Items[JwtExtensions.EntraCallerAppNotAllowedKey]);
+ }
+
+ [Fact]
+ public async Task OnTokenValidated_EntraAzpTakesPrecedenceOverAppId()
+ {
+ HttpContext httpContext = await RunOnTokenValidatedAsync(
+ EntraIssuer,
+ new() { ["azp"] = OtherAppId, ["appid"] = JwtExtensions.Agent365PlatformAppId });
+
+ Assert.Equal(OtherAppId, httpContext.Items[JwtExtensions.EntraCallerAppNotAllowedKey]);
+ }
+
+ [Fact]
+ public async Task OnTokenValidated_EntraEmptyAzp_DoesNotFallBackToAppId()
+ {
+ HttpContext httpContext = await RunOnTokenValidatedAsync(
+ EntraIssuer,
+ new() { ["azp"] = string.Empty, ["appid"] = JwtExtensions.Agent365PlatformAppId });
+
+ Assert.Equal(string.Empty, httpContext.Items[JwtExtensions.EntraCallerAppNotAllowedKey]);
+ }
+
+ [Fact]
+ public async Task OnTokenValidated_BotFrameworkToken_IsNotCheckedForCallerApp()
+ {
+ HttpContext httpContext = await RunOnTokenValidatedAsync("https://api.botframework.com", new() { ["appid"] = OtherAppId });
+
+ Assert.False(httpContext.Items.ContainsKey(JwtExtensions.EntraCallerAppNotAllowedKey));
+ }
}