Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/Microsoft.Teams.Core/BotApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </para>
/// <para>
/// Entra tokens not requested by the Agent 365 platform are rejected with 401 Unauthorized.
/// </para>
/// </remarks>
/// <param name="httpContext">The HTTP context containing the incoming bot activity request.</param>
/// <param name="cancellationToken">A cancellation token that can be used to cancel the initial deserialization. Note: a dedicated timeout governs activity processing.</param>
Expand All @@ -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(
Expand Down
48 changes: 45 additions & 3 deletions src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ namespace Microsoft.Teams.Core.Hosting
/// </summary>
public static class JwtExtensions
{
/// <summary>
/// App ID of the Agent 365 platform.
/// </summary>
internal const string Agent365PlatformAppId = "5a807f24-c9de-44ee-a3a7-329e88a00ffc";

/// <summary>
/// <see cref="HttpContext.Items"/> key set when an Entra token validated, but its caller app is not <see cref="Agent365PlatformAppId"/>.
/// The value is the caller app ID, or an empty string when the token carries none.
/// Read by <see cref="BotApplication.ProcessAsync(HttpContext, CancellationToken)"/>.
/// </summary>
internal static readonly object EntraCallerAppNotAllowedKey = new();

/// <summary>
/// Adds JWT authentication for bots and agents using configuration from appsettings.
/// </summary>
Expand Down Expand Up @@ -204,6 +216,27 @@ internal static string ResolveSigningAuthority(string? iss, string? tid, string
: $"{entraInstance}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration";
}

/// <summary>
/// Returns the client app that requested an Entra token when it is not <see cref="Agent365PlatformAppId"/>, or <see langword="null"/> when it is allowed or the token is a Bot Framework token.
/// Entra v2 tokens carry the caller in <c>azp</c> and v1 tokens carry it in <c>appid</c>.
/// <c>appid</c> is only consulted when <c>azp</c> is absent, so a present but invalid <c>azp</c> is rejected.
/// </summary>
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)
Expand Down Expand Up @@ -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;
},
Expand Down
3 changes: 3 additions & 0 deletions src/Microsoft.Teams.Core/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}")]
Expand Down
37 changes: 37 additions & 0 deletions test/Microsoft.Teams.Core.UnitTests/BotApplicationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
101 changes: 101 additions & 0 deletions test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<HttpContext> RunOnTokenValidatedAsync(string issuer, Dictionary<string, object> claims)
{
ServiceCollection services = new();
services.AddLogging();
services.AddBotAuthentication(ClientId, Tenant);
ServiceProvider provider = services.BuildServiceProvider();
JwtBearerOptions options = provider
.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>()
.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));
}
}
Loading