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); + } +}