diff --git a/core/src/Microsoft.Teams.Core/GlobalSuppressions.cs b/core/src/Microsoft.Teams.Core/GlobalSuppressions.cs
index d5928381..2e99a02c 100644
--- a/core/src/Microsoft.Teams.Core/GlobalSuppressions.cs
+++ b/core/src/Microsoft.Teams.Core/GlobalSuppressions.cs
@@ -14,3 +14,15 @@
Justification = "Callers build interpolated URLs with query strings and Uri-escaped segments; string parameters are the natural shape for this HTTP plumbing.",
Scope = "type",
Target = "~T:Microsoft.Teams.Core.Http.BotHttpClient")]
+
+[assembly: SuppressMessage("Design",
+ "CA1056:URI-like properties should not be strings",
+ Justification = "Mirrors Microsoft.Identity.Web's MicrosoftIdentityApplicationOptions.Instance convention; the value flows through as a string to configuration consumers.",
+ Scope = "member",
+ Target = "~P:Microsoft.Teams.Core.Hosting.BotConfig.OpenIdMetadataUrl")]
+
+[assembly: SuppressMessage("Design",
+ "CA1056:URI-like properties should not be strings",
+ Justification = "Mirrors Microsoft.Identity.Web's MicrosoftIdentityApplicationOptions.Instance convention; the value flows through as a string to configuration consumers.",
+ Scope = "member",
+ Target = "~P:Microsoft.Teams.Core.Hosting.BotConfig.EntraInstance")]
diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs
index 71b64bdf..b3266ef4 100644
--- a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs
+++ b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs
@@ -15,6 +15,14 @@ public sealed class BotConfig
{
internal const string DefaultSectionName = "AzureAd";
+ internal const string BotFrameworkSectionName = "BotFramework";
+
+ internal const string DefaultOpenIdMetadataUrl = "https://login.botframework.com/v1/.well-known/openid-configuration";
+
+ internal const string DefaultEntraInstance = "https://login.microsoftonline.com/";
+
+ internal const string DefaultBotTokenIssuer = "https://api.botframework.com";
+
///
/// Gets or sets the Azure AD tenant ID.
///
@@ -31,6 +39,32 @@ public sealed class BotConfig
///
public string SectionName { get; set; } = DefaultSectionName;
+ ///
+ /// Gets or sets the Bot Framework OpenID metadata URL used to fetch signing keys
+ /// for validating inbound Bot Framework tokens. For sovereign clouds, set
+ /// BotFramework:OpenIdMetadataUrl in configuration, e.g.
+ /// "https://login.botframework.azure.us/v1/.well-known/openid-configuration" for USGov.
+ /// Defaults to the public-cloud endpoint when not configured.
+ ///
+ public string OpenIdMetadataUrl { get; set; } = DefaultOpenIdMetadataUrl;
+
+ ///
+ /// Gets or sets the Entra login instance used when validating Entra-issued tokens.
+ /// For sovereign clouds, set {SectionName}:Instance in configuration
+ /// (the standard Microsoft.Identity.Web key), e.g.
+ /// "https://login.microsoftonline.us/" for USGov.
+ /// Defaults to the public-cloud instance when not configured.
+ ///
+ public string EntraInstance { get; set; } = DefaultEntraInstance;
+
+ ///
+ /// Gets or sets the expected Bot Framework token issuer used to validate inbound
+ /// Bot Framework tokens. For sovereign clouds, set BotFramework:BotTokenIssuer
+ /// in configuration, e.g. "https://api.botframework.us" for USGov.
+ /// Defaults to the public-cloud issuer when not configured.
+ ///
+ public string BotTokenIssuer { get; set; } = DefaultBotTokenIssuer;
+
internal IConfigurationSection? MsalConfigurationSection { get; set; }
///
@@ -71,10 +105,14 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName
ILogger logger = AddBotApplicationExtensions.GetLoggerFromServices(services, typeof(BotConfig));
IConfigurationSection section = configuration.GetSection(sectionName);
+ IConfigurationSection botFrameworkSection = configuration.GetSection(BotFrameworkSectionName);
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),
MsalConfigurationSection = section,
SectionName = sectionName
};
@@ -90,6 +128,23 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName
return config;
}
+ private static string ResolveAbsoluteUri(IConfigurationSection section, string key, string defaultValue)
+ {
+ ArgumentNullException.ThrowIfNull(section);
+
+ string? value = section[key];
+ if (value is null)
+ {
+ return defaultValue;
+ }
+ if (!Uri.TryCreate(value, UriKind.Absolute, out _))
+ {
+ throw new InvalidOperationException(
+ $"Configuration value '{section.Key}:{key}' is not a valid absolute URI: '{value}'.");
+ }
+ return value;
+ }
+
private static readonly Action _logUsingSectionConfig =
LoggerMessage.Define(LogLevel.Debug, new(3), "Resolved bot configuration from '{SectionName}' configuration section");
private static readonly Action _logNoConfigFound =
diff --git a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
index 79432656..eed56f35 100644
--- a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
+++ b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs
@@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -23,9 +24,6 @@ namespace Microsoft.Teams.Core.Hosting
///
public static class JwtExtensions
{
- internal const string BotOIDC = "https://login.botframework.com/v1/.well-known/openid-configuration";
- internal const string EntraOIDC = "https://login.microsoftonline.com/";
-
///
/// Adds JWT authentication for bots and agents using configuration from appsettings.
///
@@ -77,6 +75,8 @@ public static AuthenticationBuilder AddBotAuthentication(
string schemeName = BotConfig.DefaultSectionName,
ILogger? logger = null)
{
+ ArgumentNullException.ThrowIfNull(builder);
+
if (string.IsNullOrWhiteSpace(clientId))
{
builder.AddBypassAuthentication(schemeName, logger);
@@ -144,23 +144,45 @@ public static AuthorizationBuilder AddBotAuthorization(
});
}
- private static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId)
+ internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId, string entraInstance, string botTokenIssuer)
{
- // Bot Framework tokens
- if (issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase))
+ // Bot Framework tokens. The expected issuer varies by sovereign cloud
+ // (e.g. https://api.botframework.us for USGov) so it comes from configuration.
+ if (issuer.Equals(botTokenIssuer, StringComparison.OrdinalIgnoreCase))
+ {
return issuer;
+ }
- // Entra tokens � bot-to-bot (agent) and user (tab/API)
- // Use the token's own tid claim for multi-tenant; fall back to configured tenant
+ // Entra tokens - bot-to-bot (agent) and user (tab/API)
+ // Use the token's own tid claim for multi-tenant; fall back to configured tenant.
+ // The v2.0 expected issuer is derived from the configured Entra instance so sovereign
+ // tokens (e.g. login.microsoftonline.us) validate correctly.
(_, string? tid) = GetTokenClaims(token);
string? effectiveTenant = string.IsNullOrEmpty(configuredTenantId) ? tid : configuredTenantId;
if (effectiveTenant is not null &&
- (issuer == $"https://login.microsoftonline.com/{effectiveTenant}/v2.0" ||
+ (issuer == $"{entraInstance}{effectiveTenant}/v2.0" ||
issuer == $"https://sts.windows.net/{effectiveTenant}/"))
+ {
return issuer;
+ }
+
+ throw new SecurityTokenInvalidIssuerException(
+ $"Issuer '{issuer}' is not valid for tenant '{effectiveTenant ?? ""}'.");
+ }
- throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid.");
+ ///
+ /// Picks the OIDC metadata authority to fetch signing keys from based on the token's
+ /// issuer claim. Tokens issued by the configured Bot Framework issuer (e.g. the public
+ /// "https://api.botframework.com" or a sovereign equivalent like "https://api.botframework.us")
+ /// resolve to the configured Bot OIDC URL; all others fall through to the Entra tenant authority.
+ ///
+ internal static string ResolveSigningAuthority(string? iss, string? tid, string botTokenIssuer, string botOidcUrl, string entraInstance)
+ {
+ if (iss is null) return string.Empty;
+ return iss.Equals(botTokenIssuer, StringComparison.OrdinalIgnoreCase)
+ ? botOidcUrl
+ : $"{entraInstance}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration";
}
private static (string? iss, string? tid) GetTokenClaims(SecurityToken token) =>
@@ -188,6 +210,20 @@ token is JsonWebToken jwt
///
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.
+ // Defaults to the public-cloud values when IConfiguration is not registered (manual-credentials callers)
+ // or when the section is missing or doesn't override them.
+ string botOidcUrl = BotConfig.DefaultOpenIdMetadataUrl;
+ string entraInstance = BotConfig.DefaultEntraInstance;
+ string botTokenIssuer = BotConfig.DefaultBotTokenIssuer;
+ if (builder.Services.Any(d => d.ServiceType == typeof(IConfiguration)))
+ {
+ BotConfig botConfig = BotConfig.Resolve(builder.Services, schemeName);
+ botOidcUrl = botConfig.OpenIdMetadataUrl;
+ entraInstance = botConfig.EntraInstance;
+ botTokenIssuer = botConfig.BotTokenIssuer;
+ }
+
// One ConfigurationManager per OIDC authority, shared safely across all requests.
ConcurrentDictionary> configManagerCache = new(StringComparer.OrdinalIgnoreCase);
@@ -207,15 +243,13 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde
ValidateIssuer = true,
ValidateAudience = true,
ValidAudiences = [audience, $"api://{audience}"],
- IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId),
+ IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId, entraInstance, botTokenIssuer),
IssuerSigningKeyResolver = (_, securityToken, _, _) =>
{
(string? iss, string? tid) = GetTokenClaims(securityToken);
if (iss is null) return [];
- string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)
- ? BotOIDC
- : $"{EntraOIDC}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration";
+ string authority = ResolveSigningAuthority(iss, tid, botTokenIssuer, botOidcUrl, entraInstance);
logger?.ResolvingSigningKeys(authority, iss);
diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs
new file mode 100644
index 00000000..1abdc551
--- /dev/null
+++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs
@@ -0,0 +1,187 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Teams.Core.Hosting;
+
+namespace Microsoft.Teams.Core.UnitTests.Hosting;
+
+public class BotConfigTests
+{
+ private static ServiceCollection BuildServices(Dictionary configData)
+ {
+ IConfigurationRoot configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(configData)
+ .Build();
+
+ ServiceCollection services = new();
+ services.AddSingleton(configuration);
+ services.AddLogging();
+ return services;
+ }
+
+ [Fact]
+ public void Resolve_OpenIdMetadataUrl_DefaultsToPublicCloud_WhenNotConfigured()
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["AzureAd:TenantId"] = "tenant-id",
+ });
+
+ BotConfig config = BotConfig.Resolve(services);
+
+ Assert.Equal("https://login.botframework.com/v1/.well-known/openid-configuration", config.OpenIdMetadataUrl);
+ }
+
+ [Fact]
+ public void Resolve_EntraInstance_DefaultsToPublicCloud_WhenNotConfigured()
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["AzureAd:TenantId"] = "tenant-id",
+ });
+
+ BotConfig config = BotConfig.Resolve(services);
+
+ Assert.Equal("https://login.microsoftonline.com/", config.EntraInstance);
+ }
+
+ [Theory]
+ [InlineData("https://login.botframework.azure.us/v1/.well-known/openid-configuration")]
+ [InlineData("https://login.botframework.azure.cn/v1/.well-known/openid-configuration")]
+ public void Resolve_OpenIdMetadataUrl_HonorsBotFrameworkOverride(string configured)
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["AzureAd:TenantId"] = "tenant-id",
+ ["BotFramework:OpenIdMetadataUrl"] = configured,
+ });
+
+ BotConfig config = BotConfig.Resolve(services);
+
+ Assert.Equal(configured, config.OpenIdMetadataUrl);
+ }
+
+ [Theory]
+ [InlineData("https://login.microsoftonline.us/")]
+ [InlineData("https://login.partner.microsoftonline.cn/")]
+ public void Resolve_EntraInstance_HonorsAzureAdInstanceOverride(string configured)
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["AzureAd:TenantId"] = "tenant-id",
+ ["AzureAd:Instance"] = configured,
+ });
+
+ BotConfig config = BotConfig.Resolve(services);
+
+ Assert.Equal(configured, config.EntraInstance);
+ }
+
+ [Fact]
+ public void Resolve_BotTokenIssuer_DefaultsToPublicCloud_WhenNotConfigured()
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["AzureAd:TenantId"] = "tenant-id",
+ });
+
+ BotConfig config = BotConfig.Resolve(services);
+
+ Assert.Equal("https://api.botframework.com", config.BotTokenIssuer);
+ }
+
+ [Theory]
+ [InlineData("https://api.botframework.us")]
+ [InlineData("https://api.botframework.azure.cn")]
+ public void Resolve_BotTokenIssuer_HonorsBotFrameworkOverride(string configured)
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["AzureAd:TenantId"] = "tenant-id",
+ ["BotFramework:BotTokenIssuer"] = configured,
+ });
+
+ BotConfig config = BotConfig.Resolve(services);
+
+ Assert.Equal(configured, config.BotTokenIssuer);
+ }
+
+ [Fact]
+ public void Resolve_BotFrameworkSection_IsIndependentOfAzureAdSectionName()
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["CustomAuth:ClientId"] = "client-id",
+ ["BotFramework:OpenIdMetadataUrl"] = "https://login.botframework.azure.us/v1/.well-known/openid-configuration",
+ ["BotFramework:BotTokenIssuer"] = "https://api.botframework.us",
+ });
+
+ BotConfig config = BotConfig.Resolve(services, "CustomAuth");
+
+ Assert.Equal("https://login.botframework.azure.us/v1/.well-known/openid-configuration", config.OpenIdMetadataUrl);
+ Assert.Equal("https://api.botframework.us", config.BotTokenIssuer);
+ }
+
+ [Fact]
+ public void Resolve_ThrowsInvalidOperationException_WhenOpenIdMetadataUrlIsNotAbsoluteUri()
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["BotFramework:OpenIdMetadataUrl"] = "not-a-uri",
+ });
+
+ InvalidOperationException ex = Assert.Throws(() => BotConfig.Resolve(services));
+ Assert.Contains("BotFramework:OpenIdMetadataUrl", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Resolve_ThrowsInvalidOperationException_WhenBotTokenIssuerIsNotAbsoluteUri()
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["BotFramework:BotTokenIssuer"] = "not a uri",
+ });
+
+ InvalidOperationException ex = Assert.Throws(() => BotConfig.Resolve(services));
+ Assert.Contains("BotFramework:BotTokenIssuer", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Resolve_ThrowsInvalidOperationException_WhenInstanceIsNotAbsoluteUri()
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["AzureAd:Instance"] = "login.microsoftonline.us",
+ });
+
+ InvalidOperationException ex = Assert.Throws(() => BotConfig.Resolve(services));
+ Assert.Contains("AzureAd:Instance", ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Resolve_DoesNotThrow_WhenOverridesAreValidAbsoluteUris()
+ {
+ ServiceCollection services = BuildServices(new Dictionary
+ {
+ ["AzureAd:ClientId"] = "client-id",
+ ["AzureAd:Instance"] = "https://login.microsoftonline.us/",
+ ["BotFramework:OpenIdMetadataUrl"] = "https://login.botframework.azure.us/v1/.well-known/openid-configuration",
+ ["BotFramework:BotTokenIssuer"] = "https://api.botframework.us",
+ });
+
+ Exception? caught = Record.Exception(() => BotConfig.Resolve(services));
+
+ Assert.Null(caught);
+ }
+}
diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
new file mode 100644
index 00000000..91db3fde
--- /dev/null
+++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs
@@ -0,0 +1,195 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+using Microsoft.Teams.Core.Hosting;
+
+namespace Microsoft.Teams.Core.UnitTests.Hosting;
+
+public class JwtExtensionsTests
+{
+ private const string Tenant = "00000000-0000-0000-0000-000000000001";
+ private const string ClientId = "11111111-1111-1111-1111-111111111111";
+
+ private static SecurityToken FakeJsonWebToken(string tenantId)
+ {
+ // Minimal JWT-shaped string with a tid claim for token-claim extraction.
+ JsonWebTokenHandler handler = new();
+ SecurityTokenDescriptor descriptor = new()
+ {
+ Issuer = "unused-by-test",
+ Claims = new Dictionary { ["tid"] = tenantId },
+ };
+ return new JsonWebToken(handler.CreateToken(descriptor));
+ }
+
+ [Fact]
+ public void ValidateTeamsIssuer_AcceptsBotFrameworkIssuer()
+ {
+ SecurityToken token = FakeJsonWebToken(Tenant);
+
+ string result = JwtExtensions.ValidateTeamsIssuer(
+ "https://api.botframework.com", token, Tenant, "https://login.microsoftonline.com/", "https://api.botframework.com");
+
+ Assert.Equal("https://api.botframework.com", result);
+ }
+
+ [Theory]
+ [InlineData("https://api.botframework.us")]
+ [InlineData("https://api.botframework.azure.cn")]
+ public void ValidateTeamsIssuer_AcceptsSovereignBotFrameworkIssuer_WhenConfigured(string sovereignBotIssuer)
+ {
+ SecurityToken token = FakeJsonWebToken(Tenant);
+
+ string result = JwtExtensions.ValidateTeamsIssuer(
+ sovereignBotIssuer, token, Tenant, "https://login.microsoftonline.us/", sovereignBotIssuer);
+
+ Assert.Equal(sovereignBotIssuer, result);
+ }
+
+ [Fact]
+ public void ValidateTeamsIssuer_RejectsPublicBotIssuer_WhenSovereignBotIssuerConfigured()
+ {
+ SecurityToken token = FakeJsonWebToken(Tenant);
+
+ Assert.Throws(() =>
+ JwtExtensions.ValidateTeamsIssuer(
+ "https://api.botframework.com", token, Tenant,
+ "https://login.microsoftonline.us/", "https://api.botframework.us"));
+ }
+
+ [Fact]
+ public void ValidateTeamsIssuer_AcceptsPublicEntraV2Issuer()
+ {
+ SecurityToken token = FakeJsonWebToken(Tenant);
+ string issuer = $"https://login.microsoftonline.com/{Tenant}/v2.0";
+
+ string result = JwtExtensions.ValidateTeamsIssuer(
+ issuer, token, Tenant, "https://login.microsoftonline.com/", "https://api.botframework.com");
+
+ Assert.Equal(issuer, result);
+ }
+
+ [Fact]
+ public void ValidateTeamsIssuer_AcceptsSovereignEntraIssuer_WhenInstanceConfigured()
+ {
+ SecurityToken token = FakeJsonWebToken(Tenant);
+ string sovereignInstance = "https://login.microsoftonline.us/";
+ string issuer = $"{sovereignInstance}{Tenant}/v2.0";
+
+ string result = JwtExtensions.ValidateTeamsIssuer(
+ issuer, token, Tenant, sovereignInstance, "https://api.botframework.com");
+
+ Assert.Equal(issuer, result);
+ }
+
+ [Fact]
+ public void ValidateTeamsIssuer_RejectsPublicEntraIssuer_WhenSovereignInstanceConfigured()
+ {
+ SecurityToken token = FakeJsonWebToken(Tenant);
+ string publicIssuer = $"https://login.microsoftonline.com/{Tenant}/v2.0";
+
+ SecurityTokenInvalidIssuerException ex = Assert.Throws(() =>
+ JwtExtensions.ValidateTeamsIssuer(
+ publicIssuer, token, Tenant, "https://login.microsoftonline.us/", "https://api.botframework.com"));
+
+ Assert.Contains(publicIssuer, ex.Message, StringComparison.Ordinal);
+ Assert.Contains(Tenant, ex.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void ValidateTeamsIssuer_AcceptsStsWindowsNetV1Issuer()
+ {
+ SecurityToken token = FakeJsonWebToken(Tenant);
+ string issuer = $"https://sts.windows.net/{Tenant}/";
+
+ string result = JwtExtensions.ValidateTeamsIssuer(
+ issuer, token, Tenant, "https://login.microsoftonline.com/", "https://api.botframework.com");
+
+ Assert.Equal(issuer, result);
+ }
+
+ [Fact]
+ public void ResolveSigningAuthority_RoutesPublicBotIssuer_ToConfiguredBotOidcUrl()
+ {
+ string authority = JwtExtensions.ResolveSigningAuthority(
+ iss: "https://api.botframework.com",
+ tid: Tenant,
+ botTokenIssuer: "https://api.botframework.com",
+ botOidcUrl: "https://login.botframework.com/v1/.well-known/openid-configuration",
+ entraInstance: "https://login.microsoftonline.com/");
+
+ Assert.Equal("https://login.botframework.com/v1/.well-known/openid-configuration", authority);
+ }
+
+ [Theory]
+ [InlineData("https://api.botframework.us", "https://login.botframework.azure.us/v1/.well-known/openid-configuration")]
+ [InlineData("https://api.botframework.azure.cn", "https://login.botframework.azure.cn/v1/.well-known/openid-configuration")]
+ public void ResolveSigningAuthority_RoutesSovereignBotIssuer_ToConfiguredBotOidcUrl(string sovereignBotIssuer, string sovereignBotOidcUrl)
+ {
+ string authority = JwtExtensions.ResolveSigningAuthority(
+ iss: sovereignBotIssuer,
+ tid: Tenant,
+ botTokenIssuer: sovereignBotIssuer,
+ botOidcUrl: sovereignBotOidcUrl,
+ entraInstance: "https://login.microsoftonline.us/");
+
+ Assert.Equal(sovereignBotOidcUrl, authority);
+ }
+
+ [Fact]
+ public void ResolveSigningAuthority_RoutesEntraIssuer_ToInstanceDerivedAuthority()
+ {
+ string authority = JwtExtensions.ResolveSigningAuthority(
+ iss: $"https://login.microsoftonline.com/{Tenant}/v2.0",
+ tid: Tenant,
+ botTokenIssuer: "https://api.botframework.com",
+ botOidcUrl: "https://login.botframework.com/v1/.well-known/openid-configuration",
+ entraInstance: "https://login.microsoftonline.com/");
+
+ Assert.Equal($"https://login.microsoftonline.com/{Tenant}/v2.0/.well-known/openid-configuration", authority);
+ }
+
+ [Fact]
+ public void ResolveSigningAuthority_RoutesEntraIssuer_ToSovereignInstanceWhenConfigured()
+ {
+ string authority = JwtExtensions.ResolveSigningAuthority(
+ iss: $"https://login.microsoftonline.us/{Tenant}/v2.0",
+ tid: Tenant,
+ botTokenIssuer: "https://api.botframework.us",
+ botOidcUrl: "https://login.botframework.azure.us/v1/.well-known/openid-configuration",
+ entraInstance: "https://login.microsoftonline.us/");
+
+ Assert.Equal($"https://login.microsoftonline.us/{Tenant}/v2.0/.well-known/openid-configuration", authority);
+ }
+
+ [Fact]
+ public void ResolveSigningAuthority_ReturnsEmpty_WhenIssuerNull()
+ {
+ string authority = JwtExtensions.ResolveSigningAuthority(
+ iss: null,
+ tid: Tenant,
+ botTokenIssuer: "https://api.botframework.com",
+ botOidcUrl: "https://login.botframework.com/v1/.well-known/openid-configuration",
+ entraInstance: "https://login.microsoftonline.com/");
+
+ Assert.Equal(string.Empty, authority);
+ }
+
+ [Fact]
+ public void AddBotAuthentication_ManualOverload_DoesNotThrow_WhenNoIConfigurationRegistered()
+ {
+ // Regression: AddBotAuthentication(clientId, tenantId) manual overload should remain usable
+ // even when no IConfiguration is registered (e.g. plain ServiceCollection scenarios).
+ ServiceCollection services = new();
+ services.AddLogging();
+
+ Exception? caught = Record.Exception(() => services.AddBotAuthentication(ClientId, Tenant));
+
+ Assert.Null(caught);
+ }
+}