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
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ public class TeamsSettings
/// <summary>Override the Microsoft Graph token scope.</summary>
public string? GraphScope { get; set; }

/// <summary>
/// Allow the Teams messaging endpoint to accept unauthenticated requests.
/// Can be configured with the <c>Teams__DangerouslyAllowUnauthenticatedRequests</c> environment variable.
/// </summary>
public bool? DangerouslyAllowUnauthenticatedRequests { get; set; }

public bool Empty
{
get { return ClientId == "" || ClientSecret == ""; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,20 +183,24 @@ public async Task<IResult> 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);
Comment on lines 185 to 189

if (activity is null)
{
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;

Expand All @@ -223,7 +227,7 @@ public async Task<IResult> Do(HttpContext httpContext, CancellationToken cancell

var res = await Do(new ActivityEvent()
{
Token = token,
Token = activityToken,
Activity = activity,
Extra = data,
Services = httpContext.RequestServices
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

namespace Microsoft.Teams.Plugins.AspNetCore;

public class AspNetCorePluginOptions
{
/// <summary>
/// Allow the Teams messaging endpoint to accept unauthenticated requests.
/// This should only be enabled for local development.
/// </summary>
public bool DangerouslyAllowUnauthenticatedRequests { get; set; }

[Obsolete("SkipAuth is deprecated. Use DangerouslyAllowUnauthenticatedRequests instead.")]
public bool SkipAuth
{
get => DangerouslyAllowUnauthenticatedRequests;
set => DangerouslyAllowUnauthenticatedRequests = value;
}
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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<string> Scopes => [];
public override string ToString() => string.Empty;
}
Comment on lines +1 to +22

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This synthesizes trafficmanager when the activity has no service url, but TS and PY do not. Should all three have matching behavior here?

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -155,6 +166,30 @@ public async Task Test_Do_Http_ErrorPath_ProducesProblemResult()
logger.Verify(l => l.Error(It.IsAny<object[]>()), 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<string>();

EventFunction events = (plugin, name, payload, ct) =>
{
eventsCalled.Add(name);
if (name == "activity") return Task.FromResult<object?>(coreResponse);
return Task.FromResult<object?>(null);
};

var plugin = CreatePlugin(new Mock<ILogger>(), events);
var ctx = CreateUnauthenticatedRequestsAllowedHttpContext(activity);

var result = await plugin.Do(ctx);

Assert.Contains("activity", eventsCalled);
var jsonResult = Assert.IsType<Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<object?>>(result);
Assert.Equal((int)coreResponse.Status, jsonResult.StatusCode);
}

[Fact]
public void Test_ExtractToken_ReturnsToken()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,43 +41,91 @@ 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<string, string?>
{
["Teams:ClientId"] = null,
};
builder.Configuration.AddInMemoryCollection(mockSettings);
builder.AddTeams(skipAuth: false);
builder.AddTeams();
var services = builder.Build().Services;
var authOptions = services.GetRequiredService<IAuthorizationPolicyProvider>();
var pluginOptions = services.GetRequiredService<AspNetCorePluginOptions>();

var policy = await authOptions.GetPolicyAsync(TeamsTokenAuthConstants.AuthorizationPolicy);

Assert.NotNull(policy);
Assert.True(policy.Requirements.OfType<AssertionRequirement>().Any());
Assert.False(pluginOptions.DangerouslyAllowUnauthenticatedRequests);
Comment on lines 53 to +61
}

[Fact]
public async Task AddTeamsTokenAuthentication_ShouldAllowUnauthenticatedRequests_WhenDangerouslyAllowed()
{
var builder = WebApplication.CreateBuilder();
var mockSettings = new Dictionary<string, string?>
{
["Teams:ClientId"] = "test-client-id",
};
builder.Configuration.AddInMemoryCollection(mockSettings);
builder.AddTeams(new AspNetCorePluginOptions { DangerouslyAllowUnauthenticatedRequests = true });
var services = builder.Build().Services;
var authOptions = services.GetRequiredService<IAuthorizationPolicyProvider>();
var pluginOptions = services.GetRequiredService<AspNetCorePluginOptions>();

var policy = await authOptions.GetPolicyAsync(TeamsTokenAuthConstants.AuthorizationPolicy);

// Should allow all requests
Assert.NotNull(policy);
Assert.True(policy.Requirements.OfType<AssertionRequirement>().Any());
Assert.True(pluginOptions.DangerouslyAllowUnauthenticatedRequests);
Comment on lines +74 to +83
}

[Fact]
public async Task AddTeamsTokenAuthentication_ShouldSkipJwtAuthentication_WhenWithSkipIsTrue()
public async Task AddTeamsTokenAuthentication_ShouldAllowUnauthenticatedRequests_WhenConfigured()
{
var builder = WebApplication.CreateBuilder();
var mockSettings = new Dictionary<string, string?>
{
["Teams:ClientId"] = "test-client-id",
["Teams:DangerouslyAllowUnauthenticatedRequests"] = "true",
};
builder.Configuration.AddInMemoryCollection(mockSettings);
builder.AddTeams();
var services = builder.Build().Services;
var authOptions = services.GetRequiredService<IAuthorizationPolicyProvider>();
var pluginOptions = services.GetRequiredService<AspNetCorePluginOptions>();

var policy = await authOptions.GetPolicyAsync(TeamsTokenAuthConstants.AuthorizationPolicy);

Assert.NotNull(policy);
Assert.True(policy.Requirements.OfType<AssertionRequirement>().Any());
Assert.True(pluginOptions.DangerouslyAllowUnauthenticatedRequests);
Comment on lines +97 to +105
}

[Fact]
public async Task AddTeamsTokenAuthentication_ShouldAllowUnauthenticatedRequests_WhenObsoleteSkipAuthIsTrue()
{
var builder = WebApplication.CreateBuilder();
var mockSettings = new Dictionary<string, string?>
{
["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<IAuthorizationPolicyProvider>();
var pluginOptions = services.GetRequiredService<AspNetCorePluginOptions>();

var policy = await authOptions.GetPolicyAsync(TeamsTokenAuthConstants.AuthorizationPolicy);

// Should allow all requests
Assert.NotNull(policy);
Assert.True(policy.Requirements.OfType<AssertionRequirement>().Any());
Assert.True(pluginOptions.DangerouslyAllowUnauthenticatedRequests);
Comment on lines 120 to +128
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// 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<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
private static readonly Action<ILogger, Exception?> _logAuthenticationNotConfigured =
LoggerMessage.Define(
LogLevel.Warning,
new EventId(1, "AuthenticationNotConfigured"),
"Authentication is not configured. Configure ClientId or enable DangerouslyAllowUnauthenticatedRequests for local development.");

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
return Task.FromResult(AuthenticateResult.Fail("Authentication not configured"));
}

protected override async Task HandleChallengeAsync(AuthenticationProperties properties)

@MehakBindra MehakBindra Jul 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This leaves us with no startup-time warning log and defers it to when a request is actually received .

{
_logAuthenticationNotConfigured(Logger, null);

await Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "Authentication not configured"
).ExecuteAsync(Context).ConfigureAwait(false);
}
}
34 changes: 34 additions & 0 deletions core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ public sealed class BotConfig

internal const string BotFrameworkSectionName = "BotFramework";

private const string TeamsSectionName = "Teams";

@singhk97 singhk97 Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need a new section imo, we can simply support the key at the root (or default section)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we keep only one section ? and the default remains AzureAD


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/";
Expand Down Expand Up @@ -65,6 +69,12 @@ public sealed class BotConfig
/// </summary>
public string BotTokenIssuer { get; set; } = DefaultBotTokenIssuer;

/// <summary>
/// Gets or sets whether inbound bot requests should bypass authentication.
/// This should only be enabled for local development.
/// </summary>
public bool DangerouslyAllowUnauthenticatedRequests { get; set; }

internal IConfigurationSection? MsalConfigurationSection { get; set; }

/// <summary>
Expand Down Expand Up @@ -96,13 +106,18 @@ 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,
ClientId = section["ClientId"] ?? string.Empty,
EntraInstance = ResolveAbsoluteUri(section, "Instance", DefaultEntraInstance),
OpenIdMetadataUrl = ResolveAbsoluteUri(botFrameworkSection, "OpenIdMetadataUrl", DefaultOpenIdMetadataUrl),
BotTokenIssuer = ResolveAbsoluteUri(botFrameworkSection, "BotTokenIssuer", DefaultBotTokenIssuer),
DangerouslyAllowUnauthenticatedRequests = dangerouslyAllowUnauthenticatedRequests,
MsalConfigurationSection = section,
SectionName = sectionName
};
Expand All @@ -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}'.");
Comment on lines +146 to +152

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This behavior diverges from PY and TS, right? Do we want parity?

}

private static string ResolveAbsoluteUri(IConfigurationSection section, string key, string defaultValue)
{
ArgumentNullException.ThrowIfNull(section);
Expand Down
Loading
Loading