From b52db95fc1b2cefe6b4bdd55123252e7e186c865 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Wed, 13 May 2026 16:47:17 -0700 Subject: [PATCH 1/6] Make Bot OIDC URL and Entra Instance configurable via AzureAd section Adds sovereign cloud support for inbound JWT validation by reading two URLs from the AzureAd configuration section (or whatever section name is passed to AddBotAuthentication): - AzureAd:OpenIdMetadataUrl: Bot Framework OIDC metadata endpoint used to fetch signing keys for inbound Bot Framework tokens. Defaults to the public-cloud endpoint when not set. - AzureAd:Instance: Entra login instance used when validating Entra-issued tokens. Standard Microsoft.Identity.Web key. Defaults to https://login.microsoftonline.com/ when not set. Example sovereign appsettings.json: { "AzureAd": { "TenantId": "...", "ClientId": "...", "Instance": "https://login.microsoftonline.us/", "OpenIdMetadataUrl": "https://login.botframework.azure.us/v1/.well-known/openid-configuration" } } The api.botframework.com issuer string stays hardcoded; it is constant across clouds for Bot Framework tokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Microsoft.Teams.Core/Hosting/BotConfig.cs | 27 +++++ .../Hosting/JwtExtensions.cs | 15 ++- .../Hosting/BotConfigTests.cs | 99 +++++++++++++++++++ 3 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs index 71b64bdf7..6dab00171 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -15,6 +16,10 @@ public sealed class BotConfig { internal const string DefaultSectionName = "AzureAd"; + internal const string DefaultOpenIdMetadataUrl = "https://login.botframework.com/v1/.well-known/openid-configuration"; + + internal const string DefaultEntraInstance = "https://login.microsoftonline.com/"; + /// /// Gets or sets the Azure AD tenant ID. /// @@ -31,6 +36,26 @@ 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 + /// {SectionName}: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. + /// + [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.")] + 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. + /// + [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.")] + public string EntraInstance { get; set; } = DefaultEntraInstance; + internal IConfigurationSection? MsalConfigurationSection { get; set; } /// @@ -75,6 +100,8 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName { TenantId = section["TenantId"] ?? string.Empty, ClientId = section["ClientId"] ?? string.Empty, + OpenIdMetadataUrl = section["OpenIdMetadataUrl"] ?? DefaultOpenIdMetadataUrl, + EntraInstance = section["Instance"] ?? DefaultEntraInstance, MsalConfigurationSection = section, SectionName = sectionName }; diff --git a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs index 79432656a..1936a85a1 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs @@ -23,9 +23,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 +74,8 @@ public static AuthenticationBuilder AddBotAuthentication( string schemeName = BotConfig.DefaultSectionName, ILogger? logger = null) { + ArgumentNullException.ThrowIfNull(builder); + if (string.IsNullOrWhiteSpace(clientId)) { builder.AddBypassAuthentication(schemeName, logger); @@ -188,6 +187,12 @@ 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 the section is missing or doesn't override them. + BotConfig botConfig = BotConfig.Resolve(builder.Services, schemeName); + string botOidcUrl = botConfig.OpenIdMetadataUrl; + string entraInstance = botConfig.EntraInstance; + // One ConfigurationManager per OIDC authority, shared safely across all requests. ConcurrentDictionary> configManagerCache = new(StringComparer.OrdinalIgnoreCase); @@ -214,8 +219,8 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde 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"; + ? botOidcUrl + : $"{entraInstance}{tid ?? "botframework.com"}/v2.0/.well-known/openid-configuration"; 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 000000000..0142c16c6 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs @@ -0,0 +1,99 @@ +// 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_HonorsAzureAdOverride(string configured) + { + ServiceCollection services = BuildServices(new Dictionary + { + ["AzureAd:ClientId"] = "client-id", + ["AzureAd:TenantId"] = "tenant-id", + ["AzureAd: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_OpenIdMetadataUrl_ReadsFromCustomSection_WhenSectionNameProvided() + { + ServiceCollection services = BuildServices(new Dictionary + { + ["CustomAuth:ClientId"] = "client-id", + ["CustomAuth:OpenIdMetadataUrl"] = "https://login.botframework.azure.us/v1/.well-known/openid-configuration", + }); + + BotConfig config = BotConfig.Resolve(services, "CustomAuth"); + + Assert.Equal("https://login.botframework.azure.us/v1/.well-known/openid-configuration", config.OpenIdMetadataUrl); + } +} From 1438a505fb478ceeef3f0e92c566738f94530216 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Wed, 13 May 2026 17:15:29 -0700 Subject: [PATCH 2/6] Address Copilot review feedback on sovereign JWT validation Two fixes from PR review: 1. ValidateTeamsIssuer now derives the expected Entra v2.0 issuer from the configured EntraInstance instead of hardcoding the public-cloud URL. Without this, sovereign Entra tokens would fetch signing keys successfully but get rejected at issuer validation. The v1.0 sts.windows.net issuer check stays in place (covers commercial v1.0 tokens). 2. AddTeamsJwtBearer no longer requires IConfiguration to be registered. The manual-credentials overload AddBotAuthentication(clientId, tenantId, ...) is documented to work on a plain ServiceCollection without configuration, but the previous version called BotConfig.Resolve unconditionally, which throws when IConfiguration is absent. Now falls back to public-cloud defaults when IConfiguration is not registered. Test coverage: - ValidateTeamsIssuer: BF issuer, public Entra v2.0, sovereign Entra v2.0, rejection of public issuer when sovereign Instance configured, v1.0 sts.windows.net path. - AddBotAuthentication manual overload does not throw when no IConfiguration is registered. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Hosting/JwtExtensions.cs | 25 +++-- .../Hosting/JwtExtensionsTests.cs | 101 ++++++++++++++++++ 2 files changed, 118 insertions(+), 8 deletions(-) create mode 100644 core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs diff --git a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs index 1936a85a1..30e7fc3f4 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; @@ -143,19 +144,21 @@ 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) { // Bot Framework tokens if (issuer.Equals("https://api.botframework.com", 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 + // 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; @@ -188,10 +191,16 @@ 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 the section is missing or doesn't override them. - BotConfig botConfig = BotConfig.Resolve(builder.Services, schemeName); - string botOidcUrl = botConfig.OpenIdMetadataUrl; - string entraInstance = botConfig.EntraInstance; + // 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; + if (builder.Services.Any(d => d.ServiceType == typeof(IConfiguration))) + { + BotConfig botConfig = BotConfig.Resolve(builder.Services, schemeName); + botOidcUrl = botConfig.OpenIdMetadataUrl; + entraInstance = botConfig.EntraInstance; + } // One ConfigurationManager per OIDC authority, shared safely across all requests. ConcurrentDictionary> configManagerCache = new(StringComparer.OrdinalIgnoreCase); @@ -212,7 +221,7 @@ 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), IssuerSigningKeyResolver = (_, securityToken, _, _) => { (string? iss, string? tid) = GetTokenClaims(securityToken); 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 000000000..8f80c1647 --- /dev/null +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs @@ -0,0 +1,101 @@ +// 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/"); + + Assert.Equal("https://api.botframework.com", result); + } + + [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/"); + + 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); + + Assert.Equal(issuer, result); + } + + [Fact] + public void ValidateTeamsIssuer_RejectsPublicEntraIssuer_WhenSovereignInstanceConfigured() + { + SecurityToken token = FakeJsonWebToken(Tenant); + string publicIssuer = $"https://login.microsoftonline.com/{Tenant}/v2.0"; + + Assert.Throws(() => + JwtExtensions.ValidateTeamsIssuer( + publicIssuer, token, Tenant, "https://login.microsoftonline.us/")); + } + + [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/"); + + Assert.Equal(issuer, result); + } + + [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); + } +} From cce6e4482d9c3ba26c6f965a541261a374d446bb Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Wed, 13 May 2026 17:26:40 -0700 Subject: [PATCH 3/6] Add BotTokenIssuer configuration for sovereign Bot Framework tokens Adds a third sovereign-aware property on BotConfig populated from the AzureAd section, matching the existing OpenIdMetadataUrl and Instance pattern: - AzureAd:BotTokenIssuer: expected issuer claim on inbound Bot Framework tokens. Defaults to https://api.botframework.com. For sovereign clouds set to e.g. "https://api.botframework.us" for USGov. Replaces the two hardcoded "https://api.botframework.com" literals in JwtExtensions (the OIDC routing branch and the early-accept inside ValidateTeamsIssuer) with the configured value. Without this, sovereign Bot Framework tokens (which carry a per-cloud issuer claim) would fall through to the Entra validation branch, fail issuer validation, and be rejected even with OpenIdMetadataUrl pointing at the correct sovereign OIDC URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Microsoft.Teams.Core/Hosting/BotConfig.cs | 11 ++++++ .../Hosting/JwtExtensions.cs | 11 +++--- .../Hosting/BotConfigTests.cs | 31 +++++++++++++++++ .../Hosting/JwtExtensionsTests.cs | 34 ++++++++++++++++--- 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs index 6dab00171..ea6f50ca7 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs @@ -20,6 +20,8 @@ public sealed class BotConfig internal const string DefaultEntraInstance = "https://login.microsoftonline.com/"; + internal const string DefaultBotTokenIssuer = "https://api.botframework.com"; + /// /// Gets or sets the Azure AD tenant ID. /// @@ -56,6 +58,14 @@ public sealed class BotConfig [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.")] 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 {SectionName}: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; } /// @@ -102,6 +112,7 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName ClientId = section["ClientId"] ?? string.Empty, OpenIdMetadataUrl = section["OpenIdMetadataUrl"] ?? DefaultOpenIdMetadataUrl, EntraInstance = section["Instance"] ?? DefaultEntraInstance, + BotTokenIssuer = section["BotTokenIssuer"] ?? DefaultBotTokenIssuer, MsalConfigurationSection = section, SectionName = sectionName }; diff --git a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs index 30e7fc3f4..33fcdccf0 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs @@ -144,10 +144,11 @@ public static AuthorizationBuilder AddBotAuthorization( }); } - internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId, string entraInstance) + 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) @@ -195,11 +196,13 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde // 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. @@ -221,7 +224,7 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde ValidateIssuer = true, ValidateAudience = true, ValidAudiences = [audience, $"api://{audience}"], - IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId, entraInstance), + IssuerValidator = (issuer, token, _) => ValidateTeamsIssuer(issuer, token, tenantId, entraInstance, botTokenIssuer), IssuerSigningKeyResolver = (_, securityToken, _, _) => { (string? iss, string? tid) = GetTokenClaims(securityToken); diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs index 0142c16c6..9b9c93408 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs @@ -83,6 +83,37 @@ public void Resolve_EntraInstance_HonorsAzureAdInstanceOverride(string configure 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_HonorsAzureAdOverride(string configured) + { + ServiceCollection services = BuildServices(new Dictionary + { + ["AzureAd:ClientId"] = "client-id", + ["AzureAd:TenantId"] = "tenant-id", + ["AzureAd:BotTokenIssuer"] = configured, + }); + + BotConfig config = BotConfig.Resolve(services); + + Assert.Equal(configured, config.BotTokenIssuer); + } + [Fact] public void Resolve_OpenIdMetadataUrl_ReadsFromCustomSection_WhenSectionNameProvided() { diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs index 8f80c1647..af6bc903b 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs @@ -33,11 +33,35 @@ 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", 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() { @@ -45,7 +69,7 @@ public void ValidateTeamsIssuer_AcceptsPublicEntraV2Issuer() string issuer = $"https://login.microsoftonline.com/{Tenant}/v2.0"; string result = JwtExtensions.ValidateTeamsIssuer( - issuer, token, Tenant, "https://login.microsoftonline.com/"); + issuer, token, Tenant, "https://login.microsoftonline.com/", "https://api.botframework.com"); Assert.Equal(issuer, result); } @@ -58,7 +82,7 @@ public void ValidateTeamsIssuer_AcceptsSovereignEntraIssuer_WhenInstanceConfigur string issuer = $"{sovereignInstance}{Tenant}/v2.0"; string result = JwtExtensions.ValidateTeamsIssuer( - issuer, token, Tenant, sovereignInstance); + issuer, token, Tenant, sovereignInstance, "https://api.botframework.com"); Assert.Equal(issuer, result); } @@ -71,7 +95,7 @@ public void ValidateTeamsIssuer_RejectsPublicEntraIssuer_WhenSovereignInstanceCo Assert.Throws(() => JwtExtensions.ValidateTeamsIssuer( - publicIssuer, token, Tenant, "https://login.microsoftonline.us/")); + publicIssuer, token, Tenant, "https://login.microsoftonline.us/", "https://api.botframework.com")); } [Fact] @@ -81,7 +105,7 @@ public void ValidateTeamsIssuer_AcceptsStsWindowsNetV1Issuer() string issuer = $"https://sts.windows.net/{Tenant}/"; string result = JwtExtensions.ValidateTeamsIssuer( - issuer, token, Tenant, "https://login.microsoftonline.com/"); + issuer, token, Tenant, "https://login.microsoftonline.com/", "https://api.botframework.com"); Assert.Equal(issuer, result); } From 61861cb11f26a95568c4f8173296d7eb36a4062f Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Thu, 14 May 2026 15:09:41 -0700 Subject: [PATCH 4/6] Route sovereign Bot Framework tokens to BotOIDC for key resolution The previous commit threaded BotTokenIssuer through the issuer validator but missed the matching branch in IssuerSigningKeyResolver, which still compared against a hardcoded "https://api.botframework.com" literal. Effect: sovereign BF tokens (iss="https://api.botframework.us" etc.) were routed to the Entra metadata URL for signing-key resolution and failed signature validation before the issuer validator could accept them. Extracts the authority-picking logic into ResolveSigningAuthority for unit testability, and routes any token whose iss matches the configured BotTokenIssuer to BotOIDC. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Hosting/JwtExtensions.cs | 18 ++++- .../Hosting/JwtExtensionsTests.cs | 67 +++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs index 33fcdccf0..03dafacbe 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs @@ -166,6 +166,20 @@ internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, s 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) => token is JsonWebToken jwt ? (jwt.Issuer, jwt.TryGetClaim("tid", out Claim? c) ? c.Value : null) @@ -230,9 +244,7 @@ private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilde (string? iss, string? tid) = GetTokenClaims(securityToken); if (iss is null) return []; - string authority = iss.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase) - ? botOidcUrl - : $"{entraInstance}{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/JwtExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs index af6bc903b..f08860184 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs @@ -110,6 +110,73 @@ public void ValidateTeamsIssuer_AcceptsStsWindowsNetV1Issuer() 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() { From 1bcd0f518343db2522e4e2c7bfd86b7ee478423f Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Tue, 19 May 2026 12:13:51 -0700 Subject: [PATCH 5/6] Move Bot Framework overrides to BotFramework config section - OpenIdMetadataUrl and BotTokenIssuer now read from a new BotFramework section, separating Bot-Framework-specific keys from MSAL's AzureAd. - AzureAd:Instance still reads from the AzureAd section (MSAL convention). - Validate that each override is an absolute URI; throw InvalidOperationException with the section:key path on malformed input. - Consolidate CA1056 suppressions in GlobalSuppressions.cs. - Wrap the Bot Framework issuer-equality branch in braces. --- .../GlobalSuppressions.cs | 12 ++++ .../Microsoft.Teams.Core/Hosting/BotConfig.cs | 31 ++++++--- .../Hosting/JwtExtensions.cs | 2 + .../Hosting/BotConfigTests.cs | 69 +++++++++++++++++-- 4 files changed, 100 insertions(+), 14 deletions(-) diff --git a/core/src/Microsoft.Teams.Core/GlobalSuppressions.cs b/core/src/Microsoft.Teams.Core/GlobalSuppressions.cs index d59283815..2e99a02cd 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 ea6f50ca7..efd940fed 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -16,6 +15,8 @@ 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/"; @@ -41,11 +42,10 @@ public sealed class BotConfig /// /// Gets or sets the Bot Framework OpenID metadata URL used to fetch signing keys /// for validating inbound Bot Framework tokens. For sovereign clouds, set - /// {SectionName}:OpenIdMetadataUrl in configuration, e.g. + /// 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. /// - [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.")] public string OpenIdMetadataUrl { get; set; } = DefaultOpenIdMetadataUrl; /// @@ -55,12 +55,11 @@ public sealed class BotConfig /// "https://login.microsoftonline.us/" for USGov. /// Defaults to the public-cloud instance when not configured. /// - [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.")] 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 {SectionName}:BotTokenIssuer + /// 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. /// @@ -106,13 +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, - OpenIdMetadataUrl = section["OpenIdMetadataUrl"] ?? DefaultOpenIdMetadataUrl, - EntraInstance = section["Instance"] ?? DefaultEntraInstance, - BotTokenIssuer = section["BotTokenIssuer"] ?? DefaultBotTokenIssuer, + EntraInstance = ResolveAbsoluteUri(section, "Instance", DefaultEntraInstance, sectionName), + OpenIdMetadataUrl = ResolveAbsoluteUri(botFrameworkSection, "OpenIdMetadataUrl", DefaultOpenIdMetadataUrl, BotFrameworkSectionName), + BotTokenIssuer = ResolveAbsoluteUri(botFrameworkSection, "BotTokenIssuer", DefaultBotTokenIssuer, BotFrameworkSectionName), MsalConfigurationSection = section, SectionName = sectionName }; @@ -128,6 +128,21 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName return config; } + private static string ResolveAbsoluteUri(IConfigurationSection section, string key, string defaultValue, string sectionName) + { + string? value = section[key]; + if (value is null) + { + return defaultValue; + } + if (!Uri.TryCreate(value, UriKind.Absolute, out _)) + { + throw new InvalidOperationException( + $"Configuration value '{sectionName}:{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 03dafacbe..d0129ba4d 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs @@ -149,7 +149,9 @@ internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, s // 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. diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs index 9b9c93408..1abdc5518 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/BotConfigTests.cs @@ -52,13 +52,13 @@ public void Resolve_EntraInstance_DefaultsToPublicCloud_WhenNotConfigured() [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_HonorsAzureAdOverride(string configured) + public void Resolve_OpenIdMetadataUrl_HonorsBotFrameworkOverride(string configured) { ServiceCollection services = BuildServices(new Dictionary { ["AzureAd:ClientId"] = "client-id", ["AzureAd:TenantId"] = "tenant-id", - ["AzureAd:OpenIdMetadataUrl"] = configured, + ["BotFramework:OpenIdMetadataUrl"] = configured, }); BotConfig config = BotConfig.Resolve(services); @@ -100,13 +100,13 @@ public void Resolve_BotTokenIssuer_DefaultsToPublicCloud_WhenNotConfigured() [Theory] [InlineData("https://api.botframework.us")] [InlineData("https://api.botframework.azure.cn")] - public void Resolve_BotTokenIssuer_HonorsAzureAdOverride(string configured) + public void Resolve_BotTokenIssuer_HonorsBotFrameworkOverride(string configured) { ServiceCollection services = BuildServices(new Dictionary { ["AzureAd:ClientId"] = "client-id", ["AzureAd:TenantId"] = "tenant-id", - ["AzureAd:BotTokenIssuer"] = configured, + ["BotFramework:BotTokenIssuer"] = configured, }); BotConfig config = BotConfig.Resolve(services); @@ -115,16 +115,73 @@ public void Resolve_BotTokenIssuer_HonorsAzureAdOverride(string configured) } [Fact] - public void Resolve_OpenIdMetadataUrl_ReadsFromCustomSection_WhenSectionNameProvided() + public void Resolve_BotFrameworkSection_IsIndependentOfAzureAdSectionName() { ServiceCollection services = BuildServices(new Dictionary { ["CustomAuth:ClientId"] = "client-id", - ["CustomAuth:OpenIdMetadataUrl"] = "https://login.botframework.azure.us/v1/.well-known/openid-configuration", + ["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); } } From 068e60821a0f9abbc40c7d5507fd80f03dcd674d Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Wed, 20 May 2026 14:24:42 -0700 Subject: [PATCH 6/6] Address PR feedback --- core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs | 12 +++++++----- .../Microsoft.Teams.Core/Hosting/JwtExtensions.cs | 7 +++++-- .../Hosting/JwtExtensionsTests.cs | 5 ++++- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs index efd940fed..b3266ef49 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/BotConfig.cs @@ -110,9 +110,9 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName { TenantId = section["TenantId"] ?? string.Empty, ClientId = section["ClientId"] ?? string.Empty, - EntraInstance = ResolveAbsoluteUri(section, "Instance", DefaultEntraInstance, sectionName), - OpenIdMetadataUrl = ResolveAbsoluteUri(botFrameworkSection, "OpenIdMetadataUrl", DefaultOpenIdMetadataUrl, BotFrameworkSectionName), - BotTokenIssuer = ResolveAbsoluteUri(botFrameworkSection, "BotTokenIssuer", DefaultBotTokenIssuer, BotFrameworkSectionName), + EntraInstance = ResolveAbsoluteUri(section, "Instance", DefaultEntraInstance), + OpenIdMetadataUrl = ResolveAbsoluteUri(botFrameworkSection, "OpenIdMetadataUrl", DefaultOpenIdMetadataUrl), + BotTokenIssuer = ResolveAbsoluteUri(botFrameworkSection, "BotTokenIssuer", DefaultBotTokenIssuer), MsalConfigurationSection = section, SectionName = sectionName }; @@ -128,8 +128,10 @@ public static BotConfig Resolve(IServiceCollection services, string sectionName return config; } - private static string ResolveAbsoluteUri(IConfigurationSection section, string key, string defaultValue, string sectionName) + private static string ResolveAbsoluteUri(IConfigurationSection section, string key, string defaultValue) { + ArgumentNullException.ThrowIfNull(section); + string? value = section[key]; if (value is null) { @@ -138,7 +140,7 @@ private static string ResolveAbsoluteUri(IConfigurationSection section, string k if (!Uri.TryCreate(value, UriKind.Absolute, out _)) { throw new InvalidOperationException( - $"Configuration value '{sectionName}:{key}' is not a valid absolute URI: '{value}'."); + $"Configuration value '{section.Key}:{key}' is not a valid absolute URI: '{value}'."); } return value; } diff --git a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs index d0129ba4d..eed56f35e 100644 --- a/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Core/Hosting/JwtExtensions.cs @@ -153,7 +153,7 @@ internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, s return issuer; } - // Entra tokens � bot-to-bot (agent) and user (tab/API) + // 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. @@ -163,9 +163,12 @@ internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, s if (effectiveTenant is not null && (issuer == $"{entraInstance}{effectiveTenant}/v2.0" || issuer == $"https://sts.windows.net/{effectiveTenant}/")) + { return issuer; + } - throw new SecurityTokenInvalidIssuerException($"Issuer '{issuer}' is not valid."); + throw new SecurityTokenInvalidIssuerException( + $"Issuer '{issuer}' is not valid for tenant '{effectiveTenant ?? ""}'."); } /// diff --git a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs index f08860184..91db3fdeb 100644 --- a/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs +++ b/core/test/Microsoft.Teams.Core.UnitTests/Hosting/JwtExtensionsTests.cs @@ -93,9 +93,12 @@ public void ValidateTeamsIssuer_RejectsPublicEntraIssuer_WhenSovereignInstanceCo SecurityToken token = FakeJsonWebToken(Tenant); string publicIssuer = $"https://login.microsoftonline.com/{Tenant}/v2.0"; - Assert.Throws(() => + 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]