From 4004e0e9c8d04e9861e79e18ebc170afa3af2e38 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Thu, 9 Jul 2026 11:01:44 -0700 Subject: [PATCH 1/4] Add explicit unauthenticated request bypass Deprecate skipAuth in favor of DangerouslyAllowUnauthenticatedRequests and wire the setting through both AspNetCorePlugin and core TeamsBotApplication auth paths. Support configuration via Teams/AzureAd sections and keep backward compatibility for skipAuth callers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TeamsSettings.cs | 6 + .../AspNetCorePlugin.cs | 12 +- .../AspNetCorePluginOptions.cs | 20 ++ .../Extensions/HostApplicationBuilder.cs | 244 +++++++++++++++--- .../UnauthenticatedToken.cs | 22 ++ .../AspNetCorePluginTests.cs | 35 +++ .../Extensions/HostApplicationBuilderTests.cs | 56 +++- .../Microsoft.Teams.Core/Hosting/BotConfig.cs | 34 +++ .../Hosting/JwtExtensions.cs | 2 +- .../Hosting/BotConfigTests.cs | 56 ++++ .../Hosting/JwtExtensionsTests.cs | 32 +++ 11 files changed, 467 insertions(+), 52 deletions(-) create mode 100644 Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePluginOptions.cs create mode 100644 Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/UnauthenticatedToken.cs diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index d82780b4..72b4165b 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -33,6 +33,12 @@ public class TeamsSettings /// Override the Microsoft Graph token scope. public string? GraphScope { get; set; } + /// + /// Allow the Teams messaging endpoint to accept unauthenticated requests. + /// Can be configured with the Teams__DangerouslyAllowUnauthenticatedRequests environment variable. + /// + public bool? DangerouslyAllowUnauthenticatedRequests { get; set; } + public bool Empty { get { return ClientId == "" || ClientSecret == ""; } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs index a73d77d7..66591446 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.cs @@ -183,7 +183,9 @@ public async Task Do(HttpContext httpContext, CancellationToken cancell try { var request = httpContext.Request; - var token = ExtractToken(request); + var options = httpContext.RequestServices?.GetService(typeof(AspNetCorePluginOptions)) as AspNetCorePluginOptions; + var dangerouslyAllowUnauthenticatedRequests = options?.DangerouslyAllowUnauthenticatedRequests == true; + var token = dangerouslyAllowUnauthenticatedRequests ? null : ExtractToken(request); var activity = await ParseActivity(request).ConfigureAwait(false); if (activity is null) @@ -191,12 +193,14 @@ public async Task Do(HttpContext httpContext, CancellationToken cancell return Results.BadRequest("Missing activity"); } + IToken activityToken = token is null ? new UnauthenticatedToken(activity.ServiceUrl) : token; + // Require the token's serviceurl claim to match the activity's serviceUrl // (normalized, case-insensitive) when the activity specifies one. Mismatches // are logged server-side. - if (!string.IsNullOrEmpty(activity.ServiceUrl)) + if (!dangerouslyAllowUnauthenticatedRequests && !string.IsNullOrEmpty(activity.ServiceUrl)) { - var claimServiceUrl = token.Token.Payload.TryGetValue("serviceurl", out var serviceUrlClaim) + var claimServiceUrl = token!.Token.Payload.TryGetValue("serviceurl", out var serviceUrlClaim) ? serviceUrlClaim as string : null; @@ -223,7 +227,7 @@ public async Task Do(HttpContext httpContext, CancellationToken cancell var res = await Do(new ActivityEvent() { - Token = token, + Token = activityToken, Activity = activity, Extra = data, Services = httpContext.RequestServices diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePluginOptions.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePluginOptions.cs new file mode 100644 index 00000000..dfda9275 --- /dev/null +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePluginOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Plugins.AspNetCore; + +public class AspNetCorePluginOptions +{ + /// + /// Allow the Teams messaging endpoint to accept unauthenticated requests. + /// This should only be enabled for local development. + /// + public bool DangerouslyAllowUnauthenticatedRequests { get; set; } + + [Obsolete("SkipAuth is deprecated. Use DangerouslyAllowUnauthenticatedRequests instead.")] + public bool SkipAuth + { + get => DangerouslyAllowUnauthenticatedRequests; + set => DangerouslyAllowUnauthenticatedRequests = value; + } +} diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs index 6800b988..3ea72793 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs @@ -15,24 +15,86 @@ namespace Microsoft.Teams.Plugins.AspNetCore.Extensions; public static class HostApplicationBuilderExtensions { + private const string SkipAuthObsoleteMessage = "skipAuth is deprecated. Use AspNetCorePluginOptions.DangerouslyAllowUnauthenticatedRequests or the Teams:DangerouslyAllowUnauthenticatedRequests configuration value instead."; + + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder) + { + return builder.AddTeams(routing: true); + } + + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// set to false to disable the plugins default http controller + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, bool routing) + { + builder.AddTeamsCore(); + return builder.AddTeamsAspNetCorePlugin(routing, options: null); + } + + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// the AspNetCore plugin options + /// set to false to disable the plugins default http controller + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AspNetCorePluginOptions options, bool routing = true) + { + builder.AddTeamsCore(); + return builder.AddTeamsAspNetCorePlugin(routing, options); + } + /// /// adds core Teams services and the /// AspNetCorePlugin /// /// set to false to disable the plugins default http controller - /// set to true to disable token authentication + /// deprecated; use instead + [Obsolete(SkipAuthObsoleteMessage)] public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, bool routing = true, bool skipAuth = false) { builder.AddTeamsCore(); - builder.AddTeamsPlugin(); - builder.AddTeamsTokenAuthentication(skipAuth); + return builder.AddTeamsAspNetCorePlugin(routing, new AspNetCorePluginOptions { DangerouslyAllowUnauthenticatedRequests = skipAuth }); + } - if (routing) - { - builder.Services.AddControllers().AddApplicationPart(Assembly.GetExecutingAssembly()); - } + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// your app instance + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, App app) + { + return builder.AddTeams(app, routing: true); + } - return builder; + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// your app instance + /// set to false to disable the plugins default http controller + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, App app, bool routing) + { + builder.AddTeamsCore(app); + return builder.AddTeamsAspNetCorePlugin(routing, options: null); + } + + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// your app instance + /// the AspNetCore plugin options + /// set to false to disable the plugins default http controller + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, App app, AspNetCorePluginOptions options, bool routing = true) + { + builder.AddTeamsCore(app); + return builder.AddTeamsAspNetCorePlugin(routing, options); } /// @@ -41,19 +103,47 @@ public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder buil /// /// your app instance /// set to false to disable the plugins default http controller - /// set to true to disable token authentication + /// deprecated; use instead + [Obsolete(SkipAuthObsoleteMessage)] public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, App app, bool routing = true, bool skipAuth = false) { builder.AddTeamsCore(app); - builder.AddTeamsPlugin(); - builder.AddTeamsTokenAuthentication(skipAuth); + return builder.AddTeamsAspNetCorePlugin(routing, new AspNetCorePluginOptions { DangerouslyAllowUnauthenticatedRequests = skipAuth }); + } - if (routing) - { - builder.Services.AddControllers().AddApplicationPart(Assembly.GetExecutingAssembly()); - } + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// your app options + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AppOptions options) + { + return builder.AddTeams(options, routing: true); + } - return builder; + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// your app options + /// set to false to disable the plugins default http controller + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AppOptions options, bool routing) + { + builder.AddTeamsCore(options); + return builder.AddTeamsAspNetCorePlugin(routing, options: null); + } + + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// your app options + /// the AspNetCore plugin options + /// set to false to disable the plugins default http controller + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AppOptions appOptions, AspNetCorePluginOptions aspNetCoreOptions, bool routing = true) + { + builder.AddTeamsCore(appOptions); + return builder.AddTeamsAspNetCorePlugin(routing, aspNetCoreOptions); } /// @@ -62,19 +152,22 @@ public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder buil /// /// your app options /// set to false to disable the plugins default http controller - /// set to true to disable token authentication + /// deprecated; use instead + [Obsolete(SkipAuthObsoleteMessage)] public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AppOptions options, bool routing = true, bool skipAuth = false) { builder.AddTeamsCore(options); - builder.AddTeamsPlugin(); - builder.AddTeamsTokenAuthentication(skipAuth); - - if (routing) - { - builder.Services.AddControllers().AddApplicationPart(Assembly.GetExecutingAssembly()); - } + return builder.AddTeamsAspNetCorePlugin(routing, new AspNetCorePluginOptions { DangerouslyAllowUnauthenticatedRequests = skipAuth }); + } - return builder; + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// your app builder + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AppBuilder appBuilder) + { + return builder.AddTeams(appBuilder, routing: true); } /// @@ -83,19 +176,37 @@ public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder buil /// /// your app builder /// set to false to disable the plugins default http controller - /// set to true to disable token authentication - public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AppBuilder appBuilder, bool routing = true, bool skipAuth = false) + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AppBuilder appBuilder, bool routing) { builder.AddTeamsCore(appBuilder); - builder.AddTeamsPlugin(); - builder.AddTeamsTokenAuthentication(skipAuth); + return builder.AddTeamsAspNetCorePlugin(routing, options: null); + } - if (routing) - { - builder.Services.AddControllers().AddApplicationPart(Assembly.GetExecutingAssembly()); - } + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// your app builder + /// the AspNetCore plugin options + /// set to false to disable the plugins default http controller + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AppBuilder appBuilder, AspNetCorePluginOptions options, bool routing = true) + { + builder.AddTeamsCore(appBuilder); + return builder.AddTeamsAspNetCorePlugin(routing, options); + } - return builder; + /// + /// adds core Teams services and the + /// AspNetCorePlugin + /// + /// your app builder + /// set to false to disable the plugins default http controller + /// deprecated; use instead + [Obsolete(SkipAuthObsoleteMessage)] + public static IHostApplicationBuilder AddTeams(this IHostApplicationBuilder builder, AppBuilder appBuilder, bool routing = true, bool skipAuth = false) + { + builder.AddTeamsCore(appBuilder); + return builder.AddTeamsAspNetCorePlugin(routing, new AspNetCorePluginOptions { DangerouslyAllowUnauthenticatedRequests = skipAuth }); } public static class TeamsTokenAuthConstants @@ -112,36 +223,68 @@ public static class EntraTokenAuthConstants public const string AuthorizationPolicy = "EntraTokenJWTPolicy"; } + private static IHostApplicationBuilder AddTeamsAspNetCorePlugin(this IHostApplicationBuilder builder, bool routing, AspNetCorePluginOptions? options) + { + builder.AddTeamsPlugin(); + builder.AddTeamsTokenAuthentication(options); + + if (routing) + { + builder.Services.AddControllers().AddApplicationPart(Assembly.GetExecutingAssembly()); + } + + return builder; + } + /// /// add TeamsJWTScheme for validating incoming SMBA tokens and EntraTokenJWTScheme for validating incoming Entra tokens /// provides Authorization policy TeamsJWTPolicy required by [Authorize(Policy="TeamsJWTPolicy")] in MessageController /// provides Authorization policy EntraTokenJWTPolicy required when Tab invokes remote functions /// /// - public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostApplicationBuilder builder, bool skipAuth = false) + public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostApplicationBuilder builder) + { + return builder.AddTeamsTokenAuthentication(options: null); + } + + /// + /// add TeamsJWTScheme for validating incoming SMBA tokens and EntraTokenJWTScheme for validating incoming Entra tokens + /// provides Authorization policy TeamsJWTPolicy required by [Authorize(Policy="TeamsJWTPolicy")] in MessageController + /// provides Authorization policy EntraTokenJWTPolicy required when Tab invokes remote functions + /// + /// the AspNetCore plugin options + public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostApplicationBuilder builder, AspNetCorePluginOptions? options) { var settings = builder.Configuration.GetTeams(); var cloud = settings.ResolveCloud(); + var dangerouslyAllowUnauthenticatedRequests = options?.DangerouslyAllowUnauthenticatedRequests + ?? settings.DangerouslyAllowUnauthenticatedRequests + ?? false; + builder.Services.AddSingleton(new AspNetCorePluginOptions + { + DangerouslyAllowUnauthenticatedRequests = dangerouslyAllowUnauthenticatedRequests + }); var teamsValidationSettings = new TeamsValidationSettings(cloud); if (!string.IsNullOrEmpty(settings.ClientId)) { teamsValidationSettings.AddDefaultAudiences(settings.ClientId); } - else if (skipAuth) + + if (dangerouslyAllowUnauthenticatedRequests) { - // No Teams:ClientId configured and skipAuth is set, so the authorization + // DangerouslyAllowUnauthenticatedRequests is set, so the authorization // policy bypasses authentication and the bot will accept anonymous traffic. // The warning routes through whatever logging pipeline the consumer set up. LogFromServices(builder.Services, l => l.LogWarning( - "No Teams:ClientId configured and skipAuth is enabled. Bot will accept unauthenticated requests on the messaging endpoint.")); + "DangerouslyAllowUnauthenticatedRequests is enabled. Bot will accept unauthenticated requests on the messaging endpoint.")); } - else + else if (string.IsNullOrEmpty(settings.ClientId)) { - // No Teams:ClientId configured and skipAuth is not set, so the authorization + // No Teams:ClientId configured and unauthenticated requests are not allowed, so the authorization // policy rejects every request to the messaging endpoint. Warn the consumer // their bot will not receive traffic until credentials are configured (or - // skipAuth: true is passed to AddTeams(...) for local development). + // DangerouslyAllowUnauthenticatedRequests is set for local development). LogFromServices(builder.Services, l => l.LogWarning( "No Teams:ClientId configured. Bot will reject all requests on the messaging endpoint until credentials are configured.")); } @@ -162,7 +305,7 @@ public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostAppl { options.AddPolicy(TeamsTokenAuthConstants.AuthorizationPolicy, policy => { - if (skipAuth) + if (dangerouslyAllowUnauthenticatedRequests) { // bypass authentication policy.RequireAssertion(_ => true); @@ -170,7 +313,7 @@ public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostAppl else if (string.IsNullOrEmpty(settings.ClientId)) { // No credentials configured: reject all requests. Pass - // skipAuth: true to AddTeams(...) to opt into the bypass + // DangerouslyAllowUnauthenticatedRequests to AddTeams(...) to opt into the bypass // for local development without credentials. policy.RequireAssertion(_ => false); } @@ -192,6 +335,21 @@ public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostAppl return builder; } + /// + /// add TeamsJWTScheme for validating incoming SMBA tokens and EntraTokenJWTScheme for validating incoming Entra tokens + /// provides Authorization policy TeamsJWTPolicy required by [Authorize(Policy="TeamsJWTPolicy")] in MessageController + /// provides Authorization policy EntraTokenJWTPolicy required when Tab invokes remote functions + /// + /// deprecated; use instead + [Obsolete(SkipAuthObsoleteMessage)] + public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostApplicationBuilder builder, bool skipAuth) + { + return builder.AddTeamsTokenAuthentication(new AspNetCorePluginOptions + { + DangerouslyAllowUnauthenticatedRequests = skipAuth + }); + } + /// /// Invoke with an resolved from the /// service collection during DI configuration. Prefers an already-registered diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/UnauthenticatedToken.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/UnauthenticatedToken.cs new file mode 100644 index 00000000..142ec3c1 --- /dev/null +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/UnauthenticatedToken.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Teams.Api.Auth; + +namespace Microsoft.Teams.Plugins.AspNetCore; + +internal sealed class UnauthenticatedToken(string? serviceUrl) : IToken +{ + public string? AppId => null; + public string? AppDisplayName => null; + public string? TenantId => null; + public string ServiceUrl { get; } = string.IsNullOrEmpty(serviceUrl) + ? "https://smba.trafficmanager.net/teams/" + : serviceUrl.EndsWith('/') ? serviceUrl : $"{serviceUrl}/"; + public CallerType From => CallerType.Azure; + public string FromId => "urn:botframework:azure"; + public DateTime? Expiration => null; + public bool IsExpired => false; + public IEnumerable Scopes => []; + public override string ToString() => string.Empty; +} diff --git a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginTests.cs b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginTests.cs index 1f603b44..848d1164 100644 --- a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginTests.cs +++ b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Teams.Api; using Microsoft.Teams.Api.Activities; using Microsoft.Teams.Api.Auth; @@ -47,6 +48,16 @@ private static DefaultHttpContext CreateHttpContext(IActivity activity, string b return ctx; } + private static DefaultHttpContext CreateUnauthenticatedRequestsAllowedHttpContext(IActivity activity) + { + var ctx = CreateHttpContext(activity); + ctx.Request.Headers.Remove("Authorization"); + ctx.RequestServices = new ServiceCollection() + .AddSingleton(new AspNetCorePluginOptions { DangerouslyAllowUnauthenticatedRequests = true }) + .BuildServiceProvider(); + return ctx; + } + private static MessageActivity CreateMessageActivity(string? serviceUrl = null) { return new MessageActivity("hi") @@ -155,6 +166,30 @@ public async Task Test_Do_Http_ErrorPath_ProducesProblemResult() logger.Verify(l => l.Error(It.IsAny()), Times.AtLeastOnce); } + [Fact] + public async Task Test_Do_Http_DangerouslyAllowUnauthenticatedRequests_NoAuthorizationHeader_Processes() + { + var activity = CreateMessageActivity("https://smba.trafficmanager.net/teams/"); + var coreResponse = new Response(HttpStatusCode.Accepted, new { ok = true }); + var eventsCalled = new List(); + + EventFunction events = (plugin, name, payload, ct) => + { + eventsCalled.Add(name); + if (name == "activity") return Task.FromResult(coreResponse); + return Task.FromResult(null); + }; + + var plugin = CreatePlugin(new Mock(), events); + var ctx = CreateUnauthenticatedRequestsAllowedHttpContext(activity); + + var result = await plugin.Do(ctx); + + Assert.Contains("activity", eventsCalled); + var jsonResult = Assert.IsType>(result); + Assert.Equal((int)coreResponse.Status, jsonResult.StatusCode); + } + [Fact] public void Test_ExtractToken_ReturnsToken() { diff --git a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/HostApplicationBuilderTests.cs b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/HostApplicationBuilderTests.cs index 19b12ddd..504b17a5 100644 --- a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/HostApplicationBuilderTests.cs +++ b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/HostApplicationBuilderTests.cs @@ -41,7 +41,7 @@ public async Task AddTeamsTokenAuthentication_ShouldRegisterJwtBearerScheme() [Fact] - public async Task AddTeamsTokenAuthentication_ShouldSkipJwtAuthentication_WhenClientIdIsMissing() + public async Task AddTeamsTokenAuthentication_ShouldRejectRequests_WhenClientIdIsMissing() { var builder = WebApplication.CreateBuilder(); var mockSettings = new Dictionary @@ -49,35 +49,83 @@ public async Task AddTeamsTokenAuthentication_ShouldSkipJwtAuthentication_WhenCl ["Teams:ClientId"] = null, }; builder.Configuration.AddInMemoryCollection(mockSettings); - builder.AddTeams(skipAuth: false); + builder.AddTeams(); var services = builder.Build().Services; var authOptions = services.GetRequiredService(); + var pluginOptions = services.GetRequiredService(); + + var policy = await authOptions.GetPolicyAsync(TeamsTokenAuthConstants.AuthorizationPolicy); + + Assert.NotNull(policy); + Assert.True(policy.Requirements.OfType().Any()); + Assert.False(pluginOptions.DangerouslyAllowUnauthenticatedRequests); + } + + [Fact] + public async Task AddTeamsTokenAuthentication_ShouldAllowUnauthenticatedRequests_WhenDangerouslyAllowed() + { + var builder = WebApplication.CreateBuilder(); + var mockSettings = new Dictionary + { + ["Teams:ClientId"] = "test-client-id", + }; + builder.Configuration.AddInMemoryCollection(mockSettings); + builder.AddTeams(new AspNetCorePluginOptions { DangerouslyAllowUnauthenticatedRequests = true }); + var services = builder.Build().Services; + var authOptions = services.GetRequiredService(); + var pluginOptions = services.GetRequiredService(); var policy = await authOptions.GetPolicyAsync(TeamsTokenAuthConstants.AuthorizationPolicy); // Should allow all requests Assert.NotNull(policy); Assert.True(policy.Requirements.OfType().Any()); + Assert.True(pluginOptions.DangerouslyAllowUnauthenticatedRequests); } [Fact] - public async Task AddTeamsTokenAuthentication_ShouldSkipJwtAuthentication_WhenWithSkipIsTrue() + public async Task AddTeamsTokenAuthentication_ShouldAllowUnauthenticatedRequests_WhenConfigured() { var builder = WebApplication.CreateBuilder(); var mockSettings = new Dictionary { ["Teams:ClientId"] = "test-client-id", + ["Teams:DangerouslyAllowUnauthenticatedRequests"] = "true", }; builder.Configuration.AddInMemoryCollection(mockSettings); + builder.AddTeams(); + var services = builder.Build().Services; + var authOptions = services.GetRequiredService(); + var pluginOptions = services.GetRequiredService(); + + var policy = await authOptions.GetPolicyAsync(TeamsTokenAuthConstants.AuthorizationPolicy); + + Assert.NotNull(policy); + Assert.True(policy.Requirements.OfType().Any()); + Assert.True(pluginOptions.DangerouslyAllowUnauthenticatedRequests); + } + + [Fact] + public async Task AddTeamsTokenAuthentication_ShouldAllowUnauthenticatedRequests_WhenObsoleteSkipAuthIsTrue() + { + var builder = WebApplication.CreateBuilder(); + var mockSettings = new Dictionary + { + ["Teams:ClientId"] = "test-client-id", + }; + builder.Configuration.AddInMemoryCollection(mockSettings); +#pragma warning disable CS0618 // Verifies backward compatibility for the deprecated skipAuth parameter. builder.AddTeams(skipAuth: true); +#pragma warning restore CS0618 var services = builder.Build().Services; var authOptions = services.GetRequiredService(); + var pluginOptions = services.GetRequiredService(); var policy = await authOptions.GetPolicyAsync(TeamsTokenAuthConstants.AuthorizationPolicy); - // Should allow all requests Assert.NotNull(policy); Assert.True(policy.Requirements.OfType().Any()); + Assert.True(pluginOptions.DangerouslyAllowUnauthenticatedRequests); } [Fact] diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs index 6d0f427c..4fc5fb86 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs @@ -17,6 +17,10 @@ public sealed class BotConfig internal const string BotFrameworkSectionName = "BotFramework"; + private const string TeamsSectionName = "Teams"; + + private const string DangerouslyAllowUnauthenticatedRequestsKey = "DangerouslyAllowUnauthenticatedRequests"; + internal const string DefaultOpenIdMetadataUrl = "https://login.botframework.com/v1/.well-known/openid-configuration"; internal const string DefaultEntraInstance = "https://login.microsoftonline.com/"; @@ -65,6 +69,12 @@ public sealed class BotConfig /// public string BotTokenIssuer { get; set; } = DefaultBotTokenIssuer; + /// + /// Gets or sets whether inbound bot requests should bypass authentication. + /// This should only be enabled for local development. + /// + public bool DangerouslyAllowUnauthenticatedRequests { get; set; } + internal IConfigurationSection? MsalConfigurationSection { get; set; } /// @@ -96,6 +106,10 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName IConfigurationSection section = configuration.GetSection(sectionName); IConfigurationSection botFrameworkSection = configuration.GetSection(BotFrameworkSectionName); + bool dangerouslyAllowUnauthenticatedRequests = + ResolveOptionalBoolean(section, DangerouslyAllowUnauthenticatedRequestsKey) + ?? ResolveOptionalBoolean(configuration.GetSection(TeamsSectionName), DangerouslyAllowUnauthenticatedRequestsKey) + ?? false; BotConfig config = new() { TenantId = section["TenantId"] ?? string.Empty, @@ -103,6 +117,7 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName EntraInstance = ResolveAbsoluteUri(section, "Instance", DefaultEntraInstance), OpenIdMetadataUrl = ResolveAbsoluteUri(botFrameworkSection, "OpenIdMetadataUrl", DefaultOpenIdMetadataUrl), BotTokenIssuer = ResolveAbsoluteUri(botFrameworkSection, "BotTokenIssuer", DefaultBotTokenIssuer), + DangerouslyAllowUnauthenticatedRequests = dangerouslyAllowUnauthenticatedRequests, MsalConfigurationSection = section, SectionName = sectionName }; @@ -118,6 +133,25 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName return config; } + private static bool? ResolveOptionalBoolean(IConfigurationSection section, string key) + { + ArgumentNullException.ThrowIfNull(section); + + string? value = section[key]; + if (value is null) + { + return null; + } + + if (bool.TryParse(value, out bool result)) + { + return result; + } + + throw new InvalidOperationException( + $"Configuration value '{section.Path}:{key}' is not a valid boolean: '{value}'."); + } + private static string ResolveAbsoluteUri(IConfigurationSection section, string key, string defaultValue) { ArgumentNullException.ThrowIfNull(section); diff --git a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs index e228fe12..a39ed8b8 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs @@ -118,7 +118,7 @@ internal static AuthorizationBuilder AddBotAuthorization(this IServiceCollection // BotConfig.Resolve call (and duplicate startup log) that would occur through the // public string-based AddBotAuthentication → AddTeamsJwtBearer chain. AuthenticationBuilder authBuilder = services.AddAuthentication(); - if (string.IsNullOrWhiteSpace(botConfig.ClientId)) + if (botConfig.DangerouslyAllowUnauthenticatedRequests || string.IsNullOrWhiteSpace(botConfig.ClientId)) { authBuilder.AddBypassAuthentication(botConfig.SectionName, logger); } diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs index 1abdc551..aa7be78b 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs @@ -130,6 +130,49 @@ public void Resolve_BotFrameworkSection_IsIndependentOfAzureAdSectionName() Assert.Equal("https://api.botframework.us", config.BotTokenIssuer); } + [Fact] + public void Resolve_DangerouslyAllowUnauthenticatedRequests_HonorsConfiguredSection() + { + ServiceCollection services = BuildServices(new Dictionary + { + ["AzureAd:ClientId"] = "client-id", + ["AzureAd:DangerouslyAllowUnauthenticatedRequests"] = "true", + }); + + BotConfig config = BotConfig.Resolve(services); + + Assert.True(config.DangerouslyAllowUnauthenticatedRequests); + } + + [Fact] + public void Resolve_DangerouslyAllowUnauthenticatedRequests_FallsBackToTeamsSection() + { + ServiceCollection services = BuildServices(new Dictionary + { + ["AzureAd:ClientId"] = "client-id", + ["Teams:DangerouslyAllowUnauthenticatedRequests"] = "true", + }); + + BotConfig config = BotConfig.Resolve(services); + + Assert.True(config.DangerouslyAllowUnauthenticatedRequests); + } + + [Fact] + public void Resolve_DangerouslyAllowUnauthenticatedRequests_ConfiguredSectionOverridesTeamsSection() + { + ServiceCollection services = BuildServices(new Dictionary + { + ["AzureAd:ClientId"] = "client-id", + ["AzureAd:DangerouslyAllowUnauthenticatedRequests"] = "false", + ["Teams:DangerouslyAllowUnauthenticatedRequests"] = "true", + }); + + BotConfig config = BotConfig.Resolve(services); + + Assert.False(config.DangerouslyAllowUnauthenticatedRequests); + } + [Fact] public void Resolve_ThrowsInvalidOperationException_WhenOpenIdMetadataUrlIsNotAbsoluteUri() { @@ -169,6 +212,19 @@ public void Resolve_ThrowsInvalidOperationException_WhenInstanceIsNotAbsoluteUri Assert.Contains("AzureAd:Instance", ex.Message, StringComparison.Ordinal); } + [Fact] + public void Resolve_ThrowsInvalidOperationException_WhenDangerouslyAllowUnauthenticatedRequestsIsNotBoolean() + { + ServiceCollection services = BuildServices(new Dictionary + { + ["AzureAd:ClientId"] = "client-id", + ["AzureAd:DangerouslyAllowUnauthenticatedRequests"] = "not-a-bool", + }); + + InvalidOperationException ex = Assert.Throws(() => BotConfig.Resolve(services)); + Assert.Contains("AzureAd:DangerouslyAllowUnauthenticatedRequests", ex.Message, StringComparison.Ordinal); + } + [Fact] public void Resolve_DoesNotThrow_WhenOverridesAreValidAbsoluteUris() { diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs index 11f89356..6e50399d 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs @@ -2,6 +2,9 @@ // Licensed under the MIT License. using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Configuration; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; using Microsoft.Teams.Core.Hosting; @@ -190,4 +193,33 @@ public void AddBotAuthentication_ManualOverload_DoesNotThrow_WhenNoIConfiguratio Assert.Null(caught); } + + [Fact] + public async Task AddBotAuthorization_DangerouslyAllowUnauthenticatedRequests_AuthenticatesWithoutAuthorizationHeader() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = ClientId, + ["AzureAd:TenantId"] = Tenant, + ["AzureAd:DangerouslyAllowUnauthenticatedRequests"] = "true", + }) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddBotAuthorization(); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + DefaultHttpContext httpContext = new() + { + RequestServices = serviceProvider + }; + + AuthenticateResult result = await httpContext.AuthenticateAsync("AzureAd"); + + Assert.True(result.Succeeded); + Assert.Equal("BypassAuth", result.Principal?.Identity?.AuthenticationType); + } } From b66544aa2ebf09a40d003ca324514f427b40db04 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Thu, 9 Jul 2026 11:11:56 -0700 Subject: [PATCH 2/4] Return auth-not-configured when ClientId is missing Require the dangerous unauthenticated bypass flag before bypassing inbound bot authentication. When ClientId is absent without the flag, register an auth scheme that challenges with a 401 JSON error instead of accepting requests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AuthenticationNotConfiguredHandler.cs | 28 +++++++++++++ .../Hosting/JwtExtensions.cs | 39 ++++++++++++++----- .../Hosting/JwtExtensionsTests.cs | 34 ++++++++++++++++ 3 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs diff --git a/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs b/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs new file mode 100644 index 00000000..6f45034c --- /dev/null +++ b/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Teams.Core.Hosting; + +internal sealed class AuthenticationNotConfiguredHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) +{ + protected override Task HandleAuthenticateAsync() + { + return Task.FromResult(AuthenticateResult.Fail("Authentication not configured")); + } + + protected override async Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = StatusCodes.Status401Unauthorized; + Response.ContentType = "application/json"; + await Response.WriteAsync("{\"error\":\"Authentication not configured\"}").ConfigureAwait(false); + } +} diff --git a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs index a39ed8b8..58058a79 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs @@ -34,7 +34,9 @@ public static class JwtExtensions public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, string aadSectionName = BotConfig.DefaultSectionName, ILogger? logger = null) { BotConfig botConfig = BotConfig.Resolve(services, aadSectionName); - return services.AddBotAuthentication(botConfig.ClientId, botConfig.TenantId, aadSectionName, logger); + AuthenticationBuilder builder = services.AddAuthentication(); + builder.AddBotAuthentication(botConfig, logger); + return builder; } /// @@ -79,7 +81,7 @@ public static AuthenticationBuilder AddBotAuthentication( if (string.IsNullOrWhiteSpace(clientId)) { - builder.AddBypassAuthentication(schemeName, logger); + builder.AddAuthenticationNotConfigured(schemeName); } else { @@ -118,14 +120,7 @@ internal static AuthorizationBuilder AddBotAuthorization(this IServiceCollection // BotConfig.Resolve call (and duplicate startup log) that would occur through the // public string-based AddBotAuthentication → AddTeamsJwtBearer chain. AuthenticationBuilder authBuilder = services.AddAuthentication(); - if (botConfig.DangerouslyAllowUnauthenticatedRequests || string.IsNullOrWhiteSpace(botConfig.ClientId)) - { - authBuilder.AddBypassAuthentication(botConfig.SectionName, logger); - } - else - { - authBuilder.AddTeamsJwtBearer(botConfig, logger); - } + authBuilder.AddBotAuthentication(botConfig, logger); return services .AddAuthorizationBuilder() @@ -225,6 +220,30 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde logger); } + private static AuthenticationBuilder AddBotAuthentication(this AuthenticationBuilder builder, BotConfig botConfig, ILogger? logger = null) + { + if (botConfig.DangerouslyAllowUnauthenticatedRequests) + { + builder.AddBypassAuthentication(botConfig.SectionName, logger); + } + else if (string.IsNullOrWhiteSpace(botConfig.ClientId)) + { + builder.AddAuthenticationNotConfigured(botConfig.SectionName); + } + else + { + builder.AddTeamsJwtBearer(botConfig, logger); + } + + return builder; + } + + private static AuthenticationBuilder AddAuthenticationNotConfigured(this AuthenticationBuilder builder, string schemeName) + { + builder.AddScheme(schemeName, _ => { }); + return builder; + } + private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, ILogger? logger = null) { // Resolve sovereign-cloud-aware URLs from the same AzureAd section that produced clientId/tenantId. diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs index 6e50399d..6138ba62 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs @@ -222,4 +222,38 @@ public async Task AddBotAuthorization_DangerouslyAllowUnauthenticatedRequests_Au Assert.True(result.Succeeded); Assert.Equal("BypassAuth", result.Principal?.Identity?.AuthenticationType); } + + [Fact] + public async Task AddBotAuthorization_NoClientId_ChallengesWithAuthenticationNotConfigured() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AzureAd:TenantId"] = Tenant, + }) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(configuration); + services.AddLogging(); + services.AddBotAuthorization(); + + using ServiceProvider serviceProvider = services.BuildServiceProvider(); + await using MemoryStream responseBody = new(); + DefaultHttpContext httpContext = new() + { + RequestServices = serviceProvider + }; + httpContext.Response.Body = responseBody; + + AuthenticateResult result = await httpContext.AuthenticateAsync("AzureAd"); + await httpContext.ChallengeAsync("AzureAd"); + + responseBody.Position = 0; + string body = await new StreamReader(responseBody).ReadToEndAsync(); + Assert.False(result.Succeeded); + Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode); + Assert.Equal("application/json", httpContext.Response.ContentType); + Assert.Equal("{\"error\":\"Authentication not configured\"}", body); + } } From ffaf2cbb4c2ea66634371b9f415c330a72cb58ee Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Thu, 9 Jul 2026 11:14:19 -0700 Subject: [PATCH 3/4] Use ProblemDetails for auth-not-configured challenge Return the missing-auth-configuration challenge through ASP.NET Core Results.Problem instead of writing JSON manually, and assert the ProblemDetails response shape. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Hosting/AuthenticationNotConfiguredHandler.cs | 8 +++++--- .../Hosting/JwtExtensionsTests.cs | 10 ++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs b/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs index 6f45034c..d15d4bc9 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs @@ -21,8 +21,10 @@ protected override Task HandleAuthenticateAsync() protected override async Task HandleChallengeAsync(AuthenticationProperties properties) { - Response.StatusCode = StatusCodes.Status401Unauthorized; - Response.ContentType = "application/json"; - await Response.WriteAsync("{\"error\":\"Authentication not configured\"}").ConfigureAwait(false); + await Results.Problem( + statusCode: StatusCodes.Status401Unauthorized, + title: "Authentication not configured", + detail: "Configure ClientId or enable DangerouslyAllowUnauthenticatedRequests for local development." + ).ExecuteAsync(Context).ConfigureAwait(false); } } diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs index 6138ba62..f624fe8f 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs @@ -253,7 +253,13 @@ public async Task AddBotAuthorization_NoClientId_ChallengesWithAuthenticationNot string body = await new StreamReader(responseBody).ReadToEndAsync(); Assert.False(result.Succeeded); Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode); - Assert.Equal("application/json", httpContext.Response.ContentType); - Assert.Equal("{\"error\":\"Authentication not configured\"}", body); + Assert.Equal("application/problem+json", httpContext.Response.ContentType); + + using JsonDocument problem = JsonDocument.Parse(body); + Assert.Equal("Authentication not configured", problem.RootElement.GetProperty("title").GetString()); + Assert.Equal(StatusCodes.Status401Unauthorized, problem.RootElement.GetProperty("status").GetInt32()); + Assert.Equal( + "Configure ClientId or enable DangerouslyAllowUnauthenticatedRequests for local development.", + problem.RootElement.GetProperty("detail").GetString()); } } From 9b0d0648eca15dae59e263da29cd555e788b9e9c Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Thu, 9 Jul 2026 11:16:02 -0700 Subject: [PATCH 4/4] Keep auth-not-configured details out of response Log remediation guidance server-side while returning only a generic ProblemDetails title to clients. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AuthenticationNotConfiguredHandler.cs | 11 +++- .../Hosting/JwtExtensionsTests.cs | 52 +++++++++++++++++-- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs b/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs index d15d4bc9..cbc72cd8 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/AuthenticationNotConfiguredHandler.cs @@ -14,6 +14,12 @@ internal sealed class AuthenticationNotConfiguredHandler( ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler(options, logger, encoder) { + private static readonly Action _logAuthenticationNotConfigured = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, "AuthenticationNotConfigured"), + "Authentication is not configured. Configure ClientId or enable DangerouslyAllowUnauthenticatedRequests for local development."); + protected override Task HandleAuthenticateAsync() { return Task.FromResult(AuthenticateResult.Fail("Authentication not configured")); @@ -21,10 +27,11 @@ protected override Task HandleAuthenticateAsync() protected override async Task HandleChallengeAsync(AuthenticationProperties properties) { + _logAuthenticationNotConfigured(Logger, null); + await Results.Problem( statusCode: StatusCodes.Status401Unauthorized, - title: "Authentication not configured", - detail: "Configure ClientId or enable DangerouslyAllowUnauthenticatedRequests for local development." + title: "Authentication not configured" ).ExecuteAsync(Context).ConfigureAwait(false); } } diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs index f624fe8f..e8941e50 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.IdentityModel.JsonWebTokens; @@ -234,8 +235,9 @@ public async Task AddBotAuthorization_NoClientId_ChallengesWithAuthenticationNot .Build(); ServiceCollection services = new(); + ListLoggerProvider loggerProvider = new(); services.AddSingleton(configuration); - services.AddLogging(); + services.AddLogging(builder => builder.AddProvider(loggerProvider)); services.AddBotAuthorization(); using ServiceProvider serviceProvider = services.BuildServiceProvider(); @@ -258,8 +260,50 @@ public async Task AddBotAuthorization_NoClientId_ChallengesWithAuthenticationNot using JsonDocument problem = JsonDocument.Parse(body); Assert.Equal("Authentication not configured", problem.RootElement.GetProperty("title").GetString()); Assert.Equal(StatusCodes.Status401Unauthorized, problem.RootElement.GetProperty("status").GetInt32()); - Assert.Equal( - "Configure ClientId or enable DangerouslyAllowUnauthenticatedRequests for local development.", - problem.RootElement.GetProperty("detail").GetString()); + Assert.False(problem.RootElement.TryGetProperty("detail", out _)); + Assert.Contains( + "Authentication is not configured. Configure ClientId or enable DangerouslyAllowUnauthenticatedRequests for local development.", + loggerProvider.Messages); + } + + private sealed class ListLoggerProvider : ILoggerProvider + { + public List Messages { get; } = []; + + public ILogger CreateLogger(string categoryName) => new ListLogger(Messages); + + public void Dispose() + { + } + } + + private sealed class ListLogger(List messages) : ILogger + { + public IDisposable BeginScope(TState state) + where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + messages.Add(formatter(state, exception)); + } + } + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } } }