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
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..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 (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/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..e8941e50 100644
--- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
+++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
@@ -2,6 +2,10 @@
// Licensed under the MIT License.
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Teams.Core.Hosting;
@@ -190,4 +194,116 @@ 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);
+ }
+
+ [Fact]
+ public async Task AddBotAuthorization_NoClientId_ChallengesWithAuthenticationNotConfigured()
+ {
+ IConfigurationRoot configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ ["AzureAd:TenantId"] = Tenant,
+ })
+ .Build();
+
+ ServiceCollection services = new();
+ ListLoggerProvider loggerProvider = new();
+ services.AddSingleton(configuration);
+ services.AddLogging(builder => builder.AddProvider(loggerProvider));
+ 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/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.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()
+ {
+ }
+ }
}