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; + } +}