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