From ca40f212e8a6905775ed297278da53c55da8b608 Mon Sep 17 00:00:00 2001
From: Corina <14900841+corinagum@users.noreply.github.com>
Date: Fri, 9 Oct 2026 10:08:28 -0700
Subject: [PATCH 1/3] fix: validate caller app on Entra inbound Agent ID tokens
---
src/Microsoft.Teams.Core/BotApplication.cs | 10 ++
.../Hosting/JwtExtensions.cs | 47 +++++++++-
src/Microsoft.Teams.Core/Log.cs | 3 +
.../BotApplicationTests.cs | 37 ++++++++
.../Hosting/JwtExtensionsTests.cs | 91 +++++++++++++++++++
5 files changed, 185 insertions(+), 3 deletions(-)
diff --git a/src/Microsoft.Teams.Core/BotApplication.cs b/src/Microsoft.Teams.Core/BotApplication.cs
index afc5f2a8..7b6f3f28 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 issued to 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..c2a0c82f 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,26 @@ 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.
+ ///
+ internal static string? GetDisallowedEntraCallerApp(JsonWebToken token, string botTokenIssuer)
+ {
+ if (token.Issuer.Equals(botTokenIssuer, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ string? callerAppId = token.TryGetPayloadValue("azp", out string? azp) && !string.IsNullOrEmpty(azp)
+ ? azp
+ : 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 +377,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..ababcee2 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,94 @@ 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_BotFrameworkToken_IsNotCheckedForCallerApp()
+ {
+ HttpContext httpContext = await RunOnTokenValidatedAsync("https://api.botframework.com", new() { ["appid"] = OtherAppId });
+
+ Assert.False(httpContext.Items.ContainsKey(JwtExtensions.EntraCallerAppNotAllowedKey));
+ }
}
From f4eb2c8e308ea195b955d0590eba77adb0dafb6a Mon Sep 17 00:00:00 2001
From: Corina <14900841+corinagum@users.noreply.github.com>
Date: Fri, 9 Oct 2026 10:44:51 -0700
Subject: [PATCH 2/3] reject invalid azp instead of falling back to appid
---
src/Microsoft.Teams.Core/BotApplication.cs | 2 +-
src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs | 5 +++--
.../Hosting/JwtExtensionsTests.cs | 10 ++++++++++
3 files changed, 14 insertions(+), 3 deletions(-)
diff --git a/src/Microsoft.Teams.Core/BotApplication.cs b/src/Microsoft.Teams.Core/BotApplication.cs
index 7b6f3f28..2e7aab2e 100644
--- a/src/Microsoft.Teams.Core/BotApplication.cs
+++ b/src/Microsoft.Teams.Core/BotApplication.cs
@@ -180,7 +180,7 @@ public BotApplication(ConversationClient conversationClient, UserTokenClient use
/// may outlive the original HTTP connection. When a debugger is attached the timeout is disabled.
///
///
- /// Entra tokens not issued to the Agent 365 platform are rejected with 401 Unauthorized.
+ /// Entra tokens not requested by the Agent 365 platform are rejected with 401 Unauthorized.
///
///
/// The HTTP context containing the incoming bot activity request.
diff --git a/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
index c2a0c82f..a83b73eb 100644
--- a/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
+++ b/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
@@ -219,6 +219,7 @@ internal static string ResolveSigningAuthority(string? iss, string? tid, string
///
/// 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)
{
@@ -227,8 +228,8 @@ internal static string ResolveSigningAuthority(string? iss, string? tid, string
return null;
}
- string? callerAppId = token.TryGetPayloadValue("azp", out string? azp) && !string.IsNullOrEmpty(azp)
- ? azp
+ 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)
diff --git a/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
index ababcee2..56ec77d5 100644
--- a/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
+++ b/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
@@ -441,6 +441,16 @@ public async Task OnTokenValidated_EntraAzpTakesPrecedenceOverAppId()
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()
{
From b07c68922f0c6af845fcb676536eba1a2779ba92 Mon Sep 17 00:00:00 2001
From: Corina <14900841+corinagum@users.noreply.github.com>
Date: Mon, 12 Oct 2026 12:56:16 -0700
Subject: [PATCH 3/3] fix: gate M365Extensions inbound activities on the caller
app
---
.../Microsoft.Teams.Apps.csproj | 1 +
.../Hosting/JwtExtensions.cs | 46 +++++++-
.../Microsoft.Teams.Core.csproj | 1 +
.../TeamsSdkMiddleware.cs | 10 ++
.../Hosting/JwtExtensionsTests.cs | 53 +++++++++
.../CallerAppGateTests.cs | 109 ++++++++++++++++++
6 files changed, 216 insertions(+), 4 deletions(-)
create mode 100644 test/Microsoft.Teams.M365Extensions.UnitTests/CallerAppGateTests.cs
diff --git a/src/Microsoft.Teams.Apps/Microsoft.Teams.Apps.csproj b/src/Microsoft.Teams.Apps/Microsoft.Teams.Apps.csproj
index 6c80dd5a..c689ad86 100644
--- a/src/Microsoft.Teams.Apps/Microsoft.Teams.Apps.csproj
+++ b/src/Microsoft.Teams.Apps/Microsoft.Teams.Apps.csproj
@@ -34,5 +34,6 @@
+
diff --git a/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
index a83b73eb..7cb61ac1 100644
--- a/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
+++ b/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
using System.Collections.Concurrent;
+using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -228,14 +229,51 @@ internal static string ResolveSigningAuthority(string? iss, string? tid, string
return null;
}
- string? callerAppId = token.TryGetClaim("azp", out Claim? azp)
- ? azp.Value
+ return EvaluateCallerApp(ResolveCallerAppId(token));
+ }
+
+ ///
+ /// 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.
+ /// Reads the issuer and caller app from a for inbound paths that validate the token outside this package.
+ /// 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(ClaimsIdentity identity, string botTokenIssuer)
+ {
+ string? issuer = identity.FindFirst("iss")?.Value;
+ if (issuer is not null && issuer.Equals(botTokenIssuer, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return EvaluateCallerApp(ResolveCallerAppId(identity));
+ }
+
+ private static string? ResolveCallerAppId(JsonWebToken token)
+ {
+ // The token reader requires azp to be a single string and rejects an array-valued or otherwise non-string azp while parsing, so azp is read directly as a string here.
+ // appid is only consulted when azp is absent, so a present but invalid azp is rejected rather than falling back.
+ return token.TryGetPayloadValue("azp", out string? azp)
+ ? azp
: token.TryGetPayloadValue("appid", out string? appid) ? appid : null;
+ }
- return string.Equals(callerAppId, Agent365PlatformAppId, StringComparison.OrdinalIgnoreCase)
+ private static string? ResolveCallerAppId(ClaimsIdentity identity)
+ {
+ // A ClaimsIdentity flattens an array-valued azp into multiple claims, so more than one azp is treated as non-string and rejected.
+ List azpClaims = identity.FindAll("azp").ToList();
+ if (azpClaims.Count > 0)
+ {
+ return azpClaims.Count == 1 ? azpClaims[0].Value : string.Empty;
+ }
+
+ return identity.FindFirst("appid")?.Value;
+ }
+
+ private static string? EvaluateCallerApp(string? callerAppId) =>
+ string.Equals(callerAppId, Agent365PlatformAppId, StringComparison.OrdinalIgnoreCase)
? null
: callerAppId ?? string.Empty;
- }
private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) =>
token is JsonWebToken jwt
diff --git a/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj b/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj
index 2ccd3981..8cae7dc3 100644
--- a/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj
+++ b/src/Microsoft.Teams.Core/Microsoft.Teams.Core.csproj
@@ -19,6 +19,7 @@
+
diff --git a/src/Microsoft.Teams.M365Extensions/TeamsSdkMiddleware.cs b/src/Microsoft.Teams.M365Extensions/TeamsSdkMiddleware.cs
index a97aa411..44271c3c 100644
--- a/src/Microsoft.Teams.M365Extensions/TeamsSdkMiddleware.cs
+++ b/src/Microsoft.Teams.M365Extensions/TeamsSdkMiddleware.cs
@@ -8,6 +8,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Teams.Apps;
+using Microsoft.Teams.Core.Hosting;
using Microsoft.Teams.Core.Schema;
using System.Security.Claims;
using System.Text;
@@ -126,6 +127,15 @@ public async Task OnTurnAsync(ITurnContext turnContext, NextDelegate next, Cance
// Unmatched activities fall through to the Agents SDK pipeline.
if (_teamsBot.HasMatchingRoute(routeCheckActivity))
{
+ if (turnContext.Identity is ClaimsIdentity callerIdentity
+ && JwtExtensions.GetDisallowedEntraCallerApp(callerIdentity, BotConfig.DefaultBotTokenIssuer) is not null)
+ {
+ // The caller app on an Entra inbound token is not the Agent 365 platform.
+ // Tokens on this path are validated by the Agents SDK, which has usually already written the response, so there is no 401 to return here; drop the turn without routing to Teams handlers or continuing the pipeline.
+ _logger.LogWarning("TeamsSdkMiddleware: rejecting Teams activity {ActivityId}; Entra inbound token caller app is not allowed", turnContext.Activity.Id);
+ return;
+ }
+
_logger.LogDebug("TeamsSdkMiddleware: routing msteams activity {ActivityId} to Teams SDK", turnContext.Activity.Id);
// Make the Agents SDK turn context available to Teams SDK handlers.
diff --git a/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
index 56ec77d5..d7ffb285 100644
--- a/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
+++ b/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
@@ -458,4 +458,57 @@ public async Task OnTokenValidated_BotFrameworkToken_IsNotCheckedForCallerApp()
Assert.False(httpContext.Items.ContainsKey(JwtExtensions.EntraCallerAppNotAllowedKey));
}
+
+ [Fact]
+ public void GetDisallowedEntraCallerApp_ClaimsIdentity_Agent365InAzp_IsAllowed()
+ {
+ ClaimsIdentity identity = new([new Claim("iss", EntraIssuer), new Claim("azp", JwtExtensions.Agent365PlatformAppId)]);
+
+ Assert.Null(JwtExtensions.GetDisallowedEntraCallerApp(identity, "https://api.botframework.com"));
+ }
+
+ [Fact]
+ public void GetDisallowedEntraCallerApp_ClaimsIdentity_Agent365InAppIdWithoutAzp_IsAllowed()
+ {
+ ClaimsIdentity identity = new([new Claim("iss", EntraIssuer), new Claim("appid", JwtExtensions.Agent365PlatformAppId.ToUpperInvariant())]);
+
+ Assert.Null(JwtExtensions.GetDisallowedEntraCallerApp(identity, "https://api.botframework.com"));
+ }
+
+ [Fact]
+ public void GetDisallowedEntraCallerApp_ClaimsIdentity_OtherAppInAzp_IsDisallowed()
+ {
+ ClaimsIdentity identity = new([new Claim("iss", EntraIssuer), new Claim("azp", OtherAppId)]);
+
+ Assert.Equal(OtherAppId, JwtExtensions.GetDisallowedEntraCallerApp(identity, "https://api.botframework.com"));
+ }
+
+ [Fact]
+ public void GetDisallowedEntraCallerApp_ClaimsIdentity_MultipleAzpClaims_IsDisallowed()
+ {
+ // A ClaimsIdentity flattens an array-valued azp into multiple claims, so more than one azp is not a single string and is rejected rather than falling back to appid.
+ ClaimsIdentity identity = new(
+ [
+ new Claim("iss", EntraIssuer),
+ new Claim("azp", JwtExtensions.Agent365PlatformAppId),
+ new Claim("azp", OtherAppId),
+ new Claim("appid", JwtExtensions.Agent365PlatformAppId),
+ ]);
+
+ Assert.Equal(string.Empty, JwtExtensions.GetDisallowedEntraCallerApp(identity, "https://api.botframework.com"));
+ }
+
+ [Fact]
+ public void GetDisallowedEntraCallerApp_ClaimsIdentity_BotFrameworkIssuer_IsNotChecked()
+ {
+ ClaimsIdentity identity = new([new Claim("iss", "https://api.botframework.com"), new Claim("azp", OtherAppId)]);
+
+ Assert.Null(JwtExtensions.GetDisallowedEntraCallerApp(identity, "https://api.botframework.com"));
+ }
+
+ [Fact]
+ public void Agent365PlatformAppId_HasExpectedValue()
+ {
+ Assert.Equal("5a807f24-c9de-44ee-a3a7-329e88a00ffc", JwtExtensions.Agent365PlatformAppId);
+ }
}
diff --git a/test/Microsoft.Teams.M365Extensions.UnitTests/CallerAppGateTests.cs b/test/Microsoft.Teams.M365Extensions.UnitTests/CallerAppGateTests.cs
new file mode 100644
index 00000000..19f3f27a
--- /dev/null
+++ b/test/Microsoft.Teams.M365Extensions.UnitTests/CallerAppGateTests.cs
@@ -0,0 +1,109 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Security.Claims;
+using Microsoft.Agents.Builder;
+using Microsoft.Agents.Core.Models;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Teams.Apps;
+using Microsoft.Teams.Apps.Clients;
+using Microsoft.Teams.Core;
+using Moq;
+
+namespace Microsoft.Teams.M365Extensions.UnitTests;
+
+public class CallerAppGateTests
+{
+ private const string Agent365PlatformAppId = "5a807f24-c9de-44ee-a3a7-329e88a00ffc";
+ private const string OtherAppId = "22222222-2222-2222-2222-222222222222";
+ private const string EntraIssuer = "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000001/v2.0";
+
+ [Fact]
+ public async Task OnTurnAsync_ForeignCallerApp_ReachesNoHandlerAndDoesNotContinue()
+ {
+ bool handlerInvoked = false;
+ bool nextInvoked = false;
+ TeamsSdkMiddleware middleware = CreateMiddleware(() => handlerInvoked = true, out _);
+
+ await middleware.OnTurnAsync(
+ CreateTurnContext(CreateEntraIdentity("azp", OtherAppId)),
+ _ => { nextInvoked = true; return Task.CompletedTask; },
+ CancellationToken.None);
+
+ Assert.False(handlerInvoked);
+ Assert.False(nextInvoked);
+ }
+
+ [Fact]
+ public async Task OnTurnAsync_Agent365CallerApp_ReachesHandler()
+ {
+ bool handlerInvoked = false;
+ bool nextInvoked = false;
+ TeamsSdkMiddleware middleware = CreateMiddleware(() => handlerInvoked = true, out _);
+
+ await middleware.OnTurnAsync(
+ CreateTurnContext(CreateEntraIdentity("azp", Agent365PlatformAppId)),
+ _ => { nextInvoked = true; return Task.CompletedTask; },
+ CancellationToken.None);
+
+ Assert.True(handlerInvoked);
+ Assert.False(nextInvoked);
+ }
+
+ private static TeamsSdkMiddleware CreateMiddleware(Action onMessage, out TeamsBotApplication app)
+ {
+ app = CreateApp();
+ app.OnMessage((_, _) =>
+ {
+ onMessage();
+ return Task.CompletedTask;
+ });
+
+ return new TeamsSdkMiddleware(
+ app,
+ NullLogger.Instance,
+ new HttpContextAccessor(),
+ new ServiceCollection().BuildServiceProvider());
+ }
+
+ private static TeamsBotApplication CreateApp()
+ {
+ Mock userTokenClient = new(
+ new HttpClient(),
+ new Mock().Object,
+ NullLogger.Instance);
+ Mock conversationClient = new(new HttpClient(), NullLogger.Instance);
+ ApiClient apiClient = new(new HttpClient(), conversationClient.Object, userTokenClient.Object);
+
+ return new TeamsBotApplication(
+ apiClient,
+ new HttpContextAccessor(),
+ NullLogger.Instance,
+ new TeamsBotApplicationOptions { AppId = "test-app-id" });
+ }
+
+ private static ClaimsIdentity CreateEntraIdentity(string callerClaimType, string callerAppId) =>
+ new([new Claim("iss", EntraIssuer), new Claim(callerClaimType, callerAppId)]);
+
+ private static ITurnContext CreateTurnContext(ClaimsIdentity identity)
+ {
+ var activity = new Activity
+ {
+ Type = ActivityTypes.Message,
+ Id = "activity-1",
+ ChannelId = "msteams",
+ ServiceUrl = "https://smba.trafficmanager.net/teams/",
+ Conversation = new ConversationAccount { Id = "19:conversation" },
+ From = new ChannelAccount { Id = "29:from" },
+ Text = "hello",
+ };
+
+ var turnContext = new Mock();
+ turnContext.SetupGet(c => c.Activity).Returns(activity);
+ turnContext.SetupGet(c => c.Identity).Returns(identity);
+ return turnContext.Object;
+ }
+}