diff --git a/Libraries/Microsoft.Teams.Apps/App.cs b/Libraries/Microsoft.Teams.Apps/App.cs index 4d20690a..d43fdd11 100644 --- a/Libraries/Microsoft.Teams.Apps/App.cs +++ b/Libraries/Microsoft.Teams.Apps/App.cs @@ -422,6 +422,7 @@ private async Task Process(ISenderPlugin sender, ActivityEvent @event, { AppId = @event.Token.AppId ?? Id ?? string.Empty, TenantId = @event.Token.TenantId ?? string.Empty, + Cloud = _cloud, Log = Logger.Child(path), Storage = Storage, Api = api, diff --git a/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs b/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs index 74baa25d..e5b6b84a 100644 --- a/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs +++ b/Libraries/Microsoft.Teams.Apps/Contexts/Context.cs @@ -38,6 +38,11 @@ public partial interface IContext where TActivity : IActivity /// public string TenantId { get; set; } + /// + /// the cloud environment configured on the app; drives per-cloud endpoint routing + /// + public CloudEnvironment Cloud { get; set; } + /// /// the app logger instance /// @@ -130,6 +135,7 @@ public partial class Context(ISenderPlugin sender, IStreamer stream) public required string AppId { get; set; } public required string TenantId { get; set; } + public required CloudEnvironment Cloud { get; set; } public required ILogger Log { get; set; } public required IStorage Storage { get; set; } public required ApiClient Api { get; set; } @@ -176,6 +182,7 @@ public IContext ToActivityType() where TToActivity : I Sender = Sender, AppId = AppId, TenantId = TenantId, + Cloud = Cloud, Log = Log, Storage = Storage, Api = Api, diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph/ContextExtensions.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph/ContextExtensions.cs index 2ea7ef2e..db7232c7 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph/ContextExtensions.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph/ContextExtensions.cs @@ -1,4 +1,6 @@ -using Microsoft.Graph; +using System.Text.RegularExpressions; + +using Microsoft.Graph; using Microsoft.Teams.Api.Activities; using Microsoft.Teams.Apps; @@ -6,6 +8,10 @@ namespace Microsoft.Teams.Extensions.Graph; public static class ContextExtensions { + // Extracts scheme + host (+ optional port) from a URL-like scope such as + // "https://graph.microsoft.us/.default" -> "https://graph.microsoft.us". + private static readonly Regex _graphBaseUrlRegex = new(@"^(https?://[^/]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + /// /// Get user's graph client from the context. /// @@ -32,7 +38,27 @@ public static GraphServiceClient GetUserGraphClient(this IContext/.default\" value to route to the correct Graph endpoint."); + } + } + + graphClient = baseUrl is null + ? new GraphServiceClient(userGraphTokenProvider) + : new GraphServiceClient(userGraphTokenProvider, scopes: null, baseUrl: baseUrl); context.Extra["UserGraphClient"] = graphClient; return graphClient; diff --git a/Tests/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph.Tests/ContextExtensionsTests.cs b/Tests/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph.Tests/ContextExtensionsTests.cs index 6e106876..de99fccd 100644 --- a/Tests/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph.Tests/ContextExtensionsTests.cs +++ b/Tests/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Graph.Tests/ContextExtensionsTests.cs @@ -2,6 +2,7 @@ using Microsoft.Teams.Api.Activities; using Microsoft.Teams.Api.Auth; using Microsoft.Teams.Apps; +using Microsoft.Teams.Common.Logging; using Moq; @@ -59,4 +60,67 @@ public void ContextExtensions_GetUserGraphClient_ShouldReturnSingleGraphClient() Assert.NotNull(graphClient); Assert.Equal(graphClientMock, graphClient); } + + // --- Sovereign cloud Graph routing --- + + private static Mock> MockContextWith(CloudEnvironment? cloud, Mock? logger = null) + { + var token = "eyJhbGciOiJIUzI1NiJ9.eyJSb2xlIjoiQWRtaW4iLCJJc3N1ZXIiOiJJc3N1ZXIiLCJVc2VybmFtZSI6IkphdmFJblVzZSIsImV4cCI6MTc1MzI1MjAzNSwiaWF0IjoxNzUzMjUyMDM1fQ.J-DWberQuMBSnAECP0jmK-zX6BzB4o-rMEshkR0mN-A"; + var jwtToken = new JsonWebToken(token); + var context = new Mock>(); + context.Setup(c => c.UserGraphToken).Returns(jwtToken); + context.Setup(c => c.Extra).Returns(new Dictionary()); + context.Setup(c => c.Cloud).Returns(cloud!); + context.Setup(c => c.Log).Returns((logger ?? new Mock()).Object); + return context; + } + + [Theory] + [InlineData("USGov")] + [InlineData("USGovDoD")] + [InlineData("China")] + [InlineData("Public")] + public void GetUserGraphClient_RoutesToCloudSpecificBaseUrl(string cloudName) + { + var cloud = CloudEnvironment.FromName(cloudName); + var expectedBaseUrl = cloud.GraphScope.Replace("/.default", string.Empty); + var context = MockContextWith(cloud); + + var graphClient = context.Object.GetUserGraphClient(); + + Assert.NotNull(graphClient); + // Full equality rather than StartsWith: avoids the incomplete-URL-substring-sanitization + // smell (a prefix check could match ".evil.com"). + Assert.Equal(expectedBaseUrl, graphClient.RequestAdapter.BaseUrl, ignoreCase: true); + } + + [Fact] + public void GetUserGraphClient_NullCloud_UsesPublicDefault_NoWarning() + { + var logger = new Mock(); + var context = MockContextWith(cloud: null, logger: logger); + + var graphClient = context.Object.GetUserGraphClient(); + + Assert.NotNull(graphClient); + logger.Verify(l => l.Warn(It.IsAny()), Times.Never); + } + + [Fact] + public void GetUserGraphClient_NonUrlGraphScope_LogsWarningAndFallsBack() + { + var cloud = CloudEnvironment.Public.WithOverrides(graphScope: "user.read"); + var logger = new Mock(); + var context = MockContextWith(cloud, logger); + + var graphClient = context.Object.GetUserGraphClient(); + + Assert.NotNull(graphClient); + // Fallback: public base URL used (because scope didn't parse as URL) + // Fallback path uses the parameterless GraphServiceClient ctor, which the Microsoft.Graph + // SDK initializes with "https://graph.microsoft.com/v1.0" (adds /v1.0, unlike the + // explicit-baseUrl overload which stores the value verbatim). + Assert.Equal("https://graph.microsoft.com/v1.0", graphClient.RequestAdapter.BaseUrl, ignoreCase: true); + logger.Verify(l => l.Warn(It.IsAny()), Times.Once); + } } \ No newline at end of file diff --git a/core/samples/PABot/SimpleGraphClient.cs b/core/samples/PABot/SimpleGraphClient.cs index fdbd3f15..90a7d9d2 100644 --- a/core/samples/PABot/SimpleGraphClient.cs +++ b/core/samples/PABot/SimpleGraphClient.cs @@ -16,12 +16,17 @@ namespace PABot public class SimpleGraphClient { private readonly string _token; + private readonly string? _baseUrl; /// /// Initializes a new instance of the class. /// /// The token issued to the user. - public SimpleGraphClient(string token) + /// + /// Optional Graph API base URL override for sovereign clouds + /// (e.g. "https://graph.microsoft.us" for GCCH). When null, the public Graph endpoint is used. + /// + public SimpleGraphClient(string token, string? baseUrl = null) { if (string.IsNullOrWhiteSpace(token)) { @@ -29,6 +34,7 @@ public SimpleGraphClient(string token) } _token = token; + _baseUrl = baseUrl; } /// @@ -139,7 +145,9 @@ private GraphServiceClient GetAuthenticatedClient() BaseBearerTokenAuthenticationProvider authProvider = new(tokenProvider); - return new GraphServiceClient(authProvider); + return _baseUrl is null + ? new GraphServiceClient(authProvider) + : new GraphServiceClient(authProvider, _baseUrl); } public class SimpleAccessTokenProvider : IAccessTokenProvider diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs index fd43d2f2..e0df88c0 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs @@ -105,13 +105,10 @@ public static IServiceCollection AddBotApplication(this IServiceCollection /// The service collection for method chaining. internal static IServiceCollection AddBotApplication(this IServiceCollection services, BotConfig botConfig) where TApp : BotApplication { - services.AddSingleton(sp => + services.AddSingleton(_ => new BotApplicationOptions { - IConfiguration config = sp.GetRequiredService(); - return new BotApplicationOptions - { - AppId = botConfig.ClientId - }; + AppId = botConfig.ClientId, + Cloud = botConfig.Cloud }); services.AddHttpContextAccessor(); services.AddBotAuthorization(botConfig); @@ -168,6 +165,7 @@ internal static IServiceCollection AddBotClient( { options.Scope = botConfig.Scope; options.SectionName = botConfig.SectionName; + options.Cloud = botConfig.Cloud; }); // TODO: This shouldn't be called multiple times. It will being called once for each client we support. diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs index 1fbfd1de..cdb4c924 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotApplicationOptions.cs @@ -20,4 +20,9 @@ public sealed class BotApplicationOptions /// Defaults to 5 minutes. Set to to disable the timeout. /// public TimeSpan ProcessActivityTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the cloud environment. Defaults to . + /// + public CloudEnvironment Cloud { get; set; } = CloudEnvironment.Public; } diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs index 6316cc75..a16a7b3f 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs @@ -11,10 +11,15 @@ internal sealed class BotClientOptions /// /// Gets or sets the scope for bot authentication. /// - public string Scope { get; set; } = "https://api.botframework.com/.default"; + public string Scope { get; set; } = CloudEnvironment.Public.BotScope; /// /// Gets or sets the configuration section name. /// public string SectionName { get; set; } = "AzureAd"; + + /// + /// Gets or sets the resolved cloud environment. Defaults to . + /// + public CloudEnvironment Cloud { get; set; } = CloudEnvironment.Public; } diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs index 4bfef98a..bfd09afe 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs @@ -25,8 +25,6 @@ internal sealed class BotConfig /// public const string SystemManagedIdentityIdentifier = "system"; - private const string BotScope = "https://api.botframework.com/.default"; - private const string DefaultSectionName = "AzureAd"; /// @@ -56,11 +54,17 @@ internal sealed class BotConfig /// public string SectionName { get; set; } = DefaultSectionName; + /// + /// Gets or sets the cloud environment for sovereign cloud support. + /// Defaults to if not specified. + /// + public CloudEnvironment Cloud { get; set; } = CloudEnvironment.Public; + /// /// Gets or sets the scope for token acquisition. - /// Defaults to "https://api.botframework.com/.default" if not specified. + /// Defaults to the cloud environment's . /// - public string Scope { get; set; } = BotScope; + public string Scope { get; set; } = CloudEnvironment.Public.BotScope; internal IConfigurationSection? MsalConfigurationSection { get; set; } @@ -73,12 +77,14 @@ internal sealed class BotConfig public static BotConfig FromBFConfig(IConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); + var cloud = ResolveCloud(configuration["Cloud"], key => configuration[key]); return new() { TenantId = configuration["MicrosoftAppTenantId"] ?? string.Empty, ClientId = configuration["MicrosoftAppId"] ?? string.Empty, ClientSecret = configuration["MicrosoftAppPassword"], - Scope = configuration["Scope"] ?? BotScope + Cloud = cloud, + Scope = configuration["Scope"] ?? cloud.BotScope }; } @@ -95,13 +101,15 @@ public static BotConfig FromBFConfig(IConfiguration configuration) public static BotConfig FromCoreConfig(IConfiguration configuration) { ArgumentNullException.ThrowIfNull(configuration); + var cloud = ResolveCloud(configuration["CLOUD"] ?? configuration["Cloud"], key => configuration[key]); return new() { TenantId = configuration["TENANT_ID"] ?? string.Empty, ClientId = configuration["CLIENT_ID"] ?? string.Empty, ClientSecret = configuration["CLIENT_SECRET"], FicClientId = configuration["MANAGED_IDENTITY_CLIENT_ID"], - Scope = configuration["Scope"] ?? BotScope, + Cloud = cloud, + Scope = configuration["Scope"] ?? cloud.BotScope, }; } @@ -120,12 +128,14 @@ public static BotConfig FromMsalConfig(IConfiguration configuration, string sect { ArgumentNullException.ThrowIfNull(configuration); IConfigurationSection section = configuration.GetSection(sectionName); + var cloud = ResolveCloud(section["Cloud"] ?? configuration["Cloud"], key => section[key] ?? configuration[key]); return new() { TenantId = section["TenantId"] ?? string.Empty, ClientId = section["ClientId"] ?? string.Empty, ClientSecret = section["ClientSecret"], - Scope = section["Scope"] ?? BotScope, + Cloud = cloud, + Scope = section["Scope"] ?? cloud.BotScope, MsalConfigurationSection = section, SectionName = sectionName }; @@ -195,6 +205,27 @@ public static BotConfig Resolve(IConfiguration configuration, string sectionName return new BotConfig { SectionName = sectionName }; } + /// + /// Resolves a base cloud from (or Public if unset) and applies any + /// per-endpoint overrides read via . + /// Override keys: LoginEndpoint, LoginTenant, BotScope, TokenServiceUrl, OpenIdMetadataUrl, TokenIssuer, GraphScope. + /// + private static CloudEnvironment ResolveCloud(string? cloudName, Func readOverride) + { + var baseCloud = string.IsNullOrWhiteSpace(cloudName) + ? CloudEnvironment.Public + : CloudEnvironment.FromName(cloudName); + + return baseCloud.WithOverrides( + loginEndpoint: readOverride("LoginEndpoint"), + loginTenant: readOverride("LoginTenant"), + botScope: readOverride("BotScope"), + tokenServiceUrl: readOverride("TokenServiceUrl"), + openIdMetadataUrl: readOverride("OpenIdMetadataUrl"), + tokenIssuer: readOverride("TokenIssuer"), + graphScope: readOverride("GraphScope")); + } + private static readonly Action _logUsingBFConfig = LoggerMessage.Define(LogLevel.Debug, new(1), "Resolved bot configuration from Bot Framework configuration keys"); private static readonly Action _logUsingCoreConfig = diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/CloudEnvironment.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/CloudEnvironment.cs new file mode 100644 index 00000000..d9dbc9ca --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/CloudEnvironment.cs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Teams.Bot.Core.Hosting; + +/// +/// Bundles all cloud-specific service endpoints for a given Azure environment. +/// Use predefined instances (, , , ) +/// or construct a custom one. +/// +[SuppressMessage("Design", "CA1054:URI-like parameters should not be strings", + Justification = "Endpoints are stored and compared as strings to stay compatible with existing config binding and Libraries/CloudEnvironment.")] +[SuppressMessage("Design", "CA1056:URI-like properties should not be strings", + Justification = "Endpoints are stored and compared as strings to stay compatible with existing config binding and Libraries/CloudEnvironment.")] +public class CloudEnvironment +{ + /// + /// The Azure AD login endpoint (e.g. "https://login.microsoftonline.com"). + /// + public string LoginEndpoint { get; } + + /// + /// The default multi-tenant login tenant (e.g. "botframework.com"). + /// + public string LoginTenant { get; } + + /// + /// The Bot Framework OAuth scope (e.g. "https://api.botframework.com/.default"). + /// + public string BotScope { get; } + + /// + /// The Bot Framework token service base URL (e.g. "https://token.botframework.com"). + /// + public string TokenServiceUrl { get; } + + /// + /// The OpenID metadata URL for token validation. + /// + public string OpenIdMetadataUrl { get; } + + /// + /// The token issuer for Bot Framework tokens (e.g. "https://api.botframework.com"). + /// + public string TokenIssuer { get; } + + /// + /// The Microsoft Graph token scope (e.g. "https://graph.microsoft.com/.default"). + /// + public string GraphScope { get; } + + /// + /// Creates a with the given per-cloud endpoints. + /// Prefer the predefined presets (, , , ) + /// unless you need custom values. + /// + public CloudEnvironment( + string loginEndpoint, + string loginTenant, + string botScope, + string tokenServiceUrl, + string openIdMetadataUrl, + string tokenIssuer, + string graphScope) + { + ArgumentNullException.ThrowIfNull(loginEndpoint); + ArgumentNullException.ThrowIfNull(loginTenant); + ArgumentNullException.ThrowIfNull(botScope); + ArgumentNullException.ThrowIfNull(tokenServiceUrl); + ArgumentNullException.ThrowIfNull(openIdMetadataUrl); + ArgumentNullException.ThrowIfNull(tokenIssuer); + ArgumentNullException.ThrowIfNull(graphScope); + + LoginEndpoint = loginEndpoint.TrimEnd('/'); + LoginTenant = loginTenant; + BotScope = botScope; + TokenServiceUrl = tokenServiceUrl.TrimEnd('/'); + OpenIdMetadataUrl = openIdMetadataUrl; + TokenIssuer = tokenIssuer; + GraphScope = graphScope; + } + + /// Microsoft public (commercial) cloud. + public static readonly CloudEnvironment Public = new( + loginEndpoint: "https://login.microsoftonline.com", + loginTenant: "botframework.com", + botScope: "https://api.botframework.com/.default", + tokenServiceUrl: "https://token.botframework.com", + openIdMetadataUrl: "https://login.botframework.com/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.com", + graphScope: "https://graph.microsoft.com/.default" + ); + + /// US Government Community Cloud High (GCCH). + public static readonly CloudEnvironment USGov = new( + loginEndpoint: "https://login.microsoftonline.us", + loginTenant: "MicrosoftServices.onmicrosoft.us", + botScope: "https://api.botframework.us/.default", + tokenServiceUrl: "https://tokengcch.botframework.azure.us", + openIdMetadataUrl: "https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.us", + graphScope: "https://graph.microsoft.us/.default" + ); + + /// US Government Department of Defense (DoD). + public static readonly CloudEnvironment USGovDoD = new( + loginEndpoint: "https://login.microsoftonline.us", + loginTenant: "MicrosoftServices.onmicrosoft.us", + botScope: "https://api.botframework.us/.default", + tokenServiceUrl: "https://apiDoD.botframework.azure.us", + openIdMetadataUrl: "https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.us", + graphScope: "https://dod-graph.microsoft.us/.default" + ); + + /// China cloud (21Vianet). + public static readonly CloudEnvironment China = new( + loginEndpoint: "https://login.partner.microsoftonline.cn", + loginTenant: "microsoftservices.partner.onmschina.cn", + botScope: "https://api.botframework.azure.cn/.default", + tokenServiceUrl: "https://token.botframework.azure.cn", + openIdMetadataUrl: "https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.azure.cn", + graphScope: "https://microsoftgraph.chinacloudapi.cn/.default" + ); + + /// + /// Creates a new by applying non-null, non-whitespace overrides on top of this instance. + /// Returns the same instance if all overrides are effectively unset (no allocation). + /// Blank/whitespace inputs are treated as unset so empty config values don't override valid defaults. + /// + public CloudEnvironment WithOverrides( + string? loginEndpoint = null, + string? loginTenant = null, + string? botScope = null, + string? tokenServiceUrl = null, + string? openIdMetadataUrl = null, + string? tokenIssuer = null, + string? graphScope = null) + { + loginEndpoint = NullIfWhiteSpace(loginEndpoint); + loginTenant = NullIfWhiteSpace(loginTenant); + botScope = NullIfWhiteSpace(botScope); + tokenServiceUrl = NullIfWhiteSpace(tokenServiceUrl); + openIdMetadataUrl = NullIfWhiteSpace(openIdMetadataUrl); + tokenIssuer = NullIfWhiteSpace(tokenIssuer); + graphScope = NullIfWhiteSpace(graphScope); + + if (loginEndpoint is null && loginTenant is null && botScope is null && + tokenServiceUrl is null && openIdMetadataUrl is null && tokenIssuer is null && + graphScope is null) + { + return this; + } + + return new CloudEnvironment( + loginEndpoint ?? LoginEndpoint, + loginTenant ?? LoginTenant, + botScope ?? BotScope, + tokenServiceUrl ?? TokenServiceUrl, + openIdMetadataUrl ?? OpenIdMetadataUrl, + tokenIssuer ?? TokenIssuer, + graphScope ?? GraphScope + ); + } + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; + + /// + /// Resolves a cloud environment name (case-insensitive) to its corresponding instance. + /// Valid names: "Public", "USGov", "USGovDoD", "China". + /// + public static CloudEnvironment FromName(string name) + { + ArgumentNullException.ThrowIfNull(name); + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Cloud environment name cannot be empty or whitespace.", nameof(name)); + } + + return name.ToUpperInvariant() switch + { + "PUBLIC" => Public, + "USGOV" => USGov, + "USGOVDOD" => USGovDoD, + "CHINA" => China, + _ => throw new ArgumentException($"Unknown cloud environment: '{name}'. Valid values are: Public, USGov, USGovDoD, China.", nameof(name)) + }; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs index 9f4bc74b..dc6080da 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/JwtExtensions.cs @@ -24,9 +24,6 @@ namespace Microsoft.Teams.Bot.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. /// @@ -37,7 +34,7 @@ public static class JwtExtensions public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection services, string aadSectionName = "AzureAd", ILogger? logger = null) { BotConfig botConfig = ResolveBotConfig(services, aadSectionName); - return services.AddBotAuthentication(botConfig.ClientId, botConfig.TenantId, aadSectionName, logger); + return services.AddBotAuthentication(botConfig.ClientId, botConfig.TenantId, aadSectionName, logger, botConfig.Cloud); } /// @@ -48,16 +45,18 @@ public static AuthenticationBuilder AddBotAuthentication(this IServiceCollection /// The Azure AD tenant ID. Can be empty for multi-tenant scenarios. /// The authentication scheme name. Defaults to "AzureAd". /// Optional logger instance for logging. If null, a NullLogger will be used. + /// The cloud environment for sovereign cloud support. Defaults to . /// An for further authentication configuration. public static AuthenticationBuilder AddBotAuthentication( this IServiceCollection services, string clientId, string tenantId = "", string schemeName = "AzureAd", - ILogger? logger = null) + ILogger? logger = null, + CloudEnvironment? cloud = null) { AuthenticationBuilder builder = services.AddAuthentication(); - builder.AddBotAuthentication(clientId, tenantId, schemeName, logger); + builder.AddBotAuthentication(clientId, tenantId, schemeName, logger, cloud); return builder; } @@ -70,13 +69,15 @@ public static AuthenticationBuilder AddBotAuthentication( /// The Azure AD tenant ID. Can be empty for multi-tenant scenarios. /// The authentication scheme name. /// Optional logger instance for logging. If null, a NullLogger will be used. + /// The cloud environment for sovereign cloud support. Defaults to . /// The for chaining. public static AuthenticationBuilder AddBotAuthentication( this AuthenticationBuilder builder, string clientId, string tenantId = "", string schemeName = "AzureAd", - ILogger? logger = null) + ILogger? logger = null, + CloudEnvironment? cloud = null) { if (string.IsNullOrWhiteSpace(clientId)) { @@ -84,7 +85,7 @@ public static AuthenticationBuilder AddBotAuthentication( } else { - builder.AddTeamsJwtBearer(schemeName, clientId, tenantId, logger); + builder.AddTeamsJwtBearer(schemeName, clientId, tenantId, cloud ?? CloudEnvironment.Public, logger); } return builder; } @@ -115,7 +116,7 @@ internal static AuthorizationBuilder AddBotAuthorization(this IServiceCollection { logger ??= NullLogger.Instance; - return services.AddBotAuthorization(botConfig.ClientId, botConfig.TenantId, botConfig.SectionName, logger); + return services.AddBotAuthorization(botConfig.ClientId, botConfig.TenantId, botConfig.SectionName, logger, botConfig.Cloud); } /// @@ -126,15 +127,17 @@ internal static AuthorizationBuilder AddBotAuthorization(this IServiceCollection /// The Azure AD tenant ID. Can be empty for multi-tenant scenarios. /// The authentication scheme name. Defaults to "AzureAd". /// Optional logger instance for logging. If null, a NullLogger will be used. + /// The cloud environment for sovereign cloud support. Defaults to . /// An for further authorization configuration. public static AuthorizationBuilder AddBotAuthorization( this IServiceCollection services, string clientId, string tenantId = "", string schemeName = "AzureAd", - ILogger? logger = null) + ILogger? logger = null, + CloudEnvironment? cloud = null) { - services.AddBotAuthentication(clientId, tenantId, schemeName, logger); + services.AddBotAuthentication(clientId, tenantId, schemeName, logger, cloud); return services .AddAuthorizationBuilder() @@ -145,19 +148,19 @@ public static AuthorizationBuilder AddBotAuthorization( }); } - private static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId) + internal static string ValidateTeamsIssuer(string issuer, SecurityToken token, string configuredTenantId, CloudEnvironment cloud) { // Bot Framework tokens - if (issuer.Equals("https://api.botframework.com", StringComparison.OrdinalIgnoreCase)) + if (issuer.Equals(cloud.TokenIssuer, StringComparison.OrdinalIgnoreCase)) 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 (_, 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 == $"{cloud.LoginEndpoint}/{effectiveTenant}/v2.0" || issuer == $"https://sts.windows.net/{effectiveTenant}/")) return issuer; @@ -176,18 +179,19 @@ token is JsonWebToken jwt /// The authentication scheme name. /// The application (client) ID used to validate the audience of tokens. /// The Azure AD tenant ID. + /// The cloud environment providing per-cloud endpoints (issuer, OIDC metadata, login). /// Optional logger for authentication events. /// The authentication builder for chaining. /// /// This method configures authentication to support both types of tokens: /// - /// Bot Framework tokens: Issued by the Bot Connector service when channels send activities to your bot (issuer: https://api.botframework.com). - /// Entra ID tokens: Issued by Azure AD when the bot is registered as an agentic application (issuer: https://login.microsoftonline.com). See https://learn.microsoft.com/en-us/microsoft-agent-365/developer/identity#understanding-agent-identity-components + /// Bot Framework tokens: Issued by the Bot Connector service when channels send activities to your bot. The expected issuer is . + /// Entra ID tokens: Issued by Azure AD when the bot is registered as an agentic application. The expected issuer is derived from . See https://learn.microsoft.com/en-us/microsoft-agent-365/developer/identity#understanding-agent-identity-components /// /// The signing keys for both token types are dynamically resolved at runtime using OpenID Connect discovery, /// allowing the same authentication configuration to validate tokens from multiple issuers. /// - private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, ILogger? logger = null) + private static AuthenticationBuilder AddTeamsJwtBearer(this AuthenticationBuilder builder, string schemeName, string audience, string tenantId, CloudEnvironment cloud, ILogger? logger = null) { // One ConfigurationManager per OIDC authority, shared safely across all requests. ConcurrentDictionary> configManagerCache = new(StringComparer.OrdinalIgnoreCase); @@ -203,15 +207,15 @@ 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, cloud), 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 = iss.Equals(cloud.TokenIssuer, StringComparison.OrdinalIgnoreCase) + ? cloud.OpenIdMetadataUrl + : $"{cloud.LoginEndpoint}/{tid ?? cloud.LoginTenant}/v2.0/.well-known/openid-configuration"; logger.LogTraceGuarded("Resolving signing keys from OIDC authority '{Authority}' for issuer '{Issuer}'.", authority, iss); diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/MsalConfigurationExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/MsalConfigurationExtensions.cs index 2dfe2368..c9ea7ee6 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/MsalConfigurationExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/MsalConfigurationExtensions.cs @@ -33,7 +33,7 @@ internal static bool ConfigureMSAL(this IServiceCollection services, BotConfig b } else if (botConfig.MsalConfigurationSection != null) { - services.ConfigureMSALFromConfig(botConfig.MsalConfigurationSection); + services.ConfigureMSALFromConfig(botConfig.MsalConfigurationSection, botConfig.Cloud); } else { @@ -43,14 +43,54 @@ internal static bool ConfigureMSAL(this IServiceCollection services, BotConfig b return true; } - private static IServiceCollection ConfigureMSALFromConfig(this IServiceCollection services, IConfigurationSection msalConfigSection) + private static IServiceCollection ConfigureMSALFromConfig(this IServiceCollection services, IConfigurationSection msalConfigSection, CloudEnvironment cloud) { ArgumentNullException.ThrowIfNull(msalConfigSection); + + string? sectionInstance = msalConfigSection["Instance"]; + string? sectionAuthority = msalConfigSection["Authority"]; + ValidateSectionMatchesCloud(msalConfigSection.Path, sectionInstance, sectionAuthority, cloud); + services.Configure(MsalConfigKey, msalConfigSection); + + // Fall back to Cloud's login endpoint when the section didn't set Instance or Authority. + // Lets `Cloud: "USGov"` alone configure sovereign correctly; honors the section when it's explicit. + if (string.IsNullOrWhiteSpace(sectionInstance) && string.IsNullOrWhiteSpace(sectionAuthority)) + { + services.Configure(MsalConfigKey, options => + { + options.Instance = cloud.LoginEndpoint + "/"; + }); + } return services; } - private static IServiceCollection ConfigureMSALWithSecret(this IServiceCollection services, string tenantId, string clientId, string clientSecret) + private static void ValidateSectionMatchesCloud(string sectionPath, string? sectionInstance, string? sectionAuthority, CloudEnvironment cloud) + { + string expected = NormalizeEndpoint(cloud.LoginEndpoint); + + if (!string.IsNullOrWhiteSpace(sectionInstance) && + !NormalizeEndpoint(sectionInstance).Equals(expected, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"MSAL configuration conflict: '{sectionPath}:Instance' is '{sectionInstance}' " + + $"but Cloud resolves login endpoint to '{cloud.LoginEndpoint}'. " + + $"Either remove Instance from the MSAL section (Cloud will set it) or change Cloud to match."); + } + + if (!string.IsNullOrWhiteSpace(sectionAuthority) && + !NormalizeEndpoint(sectionAuthority).StartsWith(expected, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"MSAL configuration conflict: '{sectionPath}:Authority' is '{sectionAuthority}' " + + $"but Cloud resolves login endpoint to '{cloud.LoginEndpoint}'. " + + $"Either remove Authority from the MSAL section (Cloud will set Instance) or change Cloud to match."); + } + } + + private static string NormalizeEndpoint(string value) => value.TrimEnd('/'); + + private static IServiceCollection ConfigureMSALWithSecret(this IServiceCollection services, string tenantId, string clientId, string clientSecret, CloudEnvironment cloud) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(clientId); @@ -58,7 +98,7 @@ private static IServiceCollection ConfigureMSALWithSecret(this IServiceCollectio services.Configure(MsalConfigKey, options => { - options.Instance = "https://login.microsoftonline.com/"; + options.Instance = cloud.LoginEndpoint + "/"; options.TenantId = tenantId; options.ClientId = clientId; options.ClientCredentials = [ @@ -72,7 +112,7 @@ private static IServiceCollection ConfigureMSALWithSecret(this IServiceCollectio return services; } - private static IServiceCollection ConfigureMSALWithFIC(this IServiceCollection services, string tenantId, string clientId, string? ficClientId) + private static IServiceCollection ConfigureMSALWithFIC(this IServiceCollection services, string tenantId, string clientId, string? ficClientId, CloudEnvironment cloud) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(clientId); @@ -88,7 +128,7 @@ private static IServiceCollection ConfigureMSALWithFIC(this IServiceCollection s services.Configure(MsalConfigKey, options => { - options.Instance = "https://login.microsoftonline.com/"; + options.Instance = cloud.LoginEndpoint + "/"; options.TenantId = tenantId; options.ClientId = clientId; options.ClientCredentials = [ @@ -98,7 +138,7 @@ private static IServiceCollection ConfigureMSALWithFIC(this IServiceCollection s return services; } - private static IServiceCollection ConfigureMSALWithUMI(this IServiceCollection services, string tenantId, string clientId, string? managedIdentityClientId = null) + private static IServiceCollection ConfigureMSALWithUMI(this IServiceCollection services, string tenantId, string clientId, CloudEnvironment cloud, string? managedIdentityClientId = null) { ArgumentNullException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentNullException.ThrowIfNullOrWhiteSpace(clientId); @@ -114,7 +154,7 @@ private static IServiceCollection ConfigureMSALWithUMI(this IServiceCollection s services.Configure(MsalConfigKey, options => { - options.Instance = "https://login.microsoftonline.com/"; + options.Instance = cloud.LoginEndpoint + "/"; options.TenantId = tenantId; options.ClientId = clientId; }); @@ -127,18 +167,18 @@ private static IServiceCollection ConfigureMSALFromBotConfig(this IServiceCollec if (!string.IsNullOrEmpty(botConfig.ClientSecret)) { _logUsingClientSecret(logger, null); - services.ConfigureMSALWithSecret(botConfig.TenantId, botConfig.ClientId, botConfig.ClientSecret); + services.ConfigureMSALWithSecret(botConfig.TenantId, botConfig.ClientId, botConfig.ClientSecret, botConfig.Cloud); } else if (string.IsNullOrEmpty(botConfig.FicClientId) || botConfig.FicClientId == botConfig.ClientId) { _logUsingUMI(logger, null); - services.ConfigureMSALWithUMI(botConfig.TenantId, botConfig.ClientId, botConfig.FicClientId); + services.ConfigureMSALWithUMI(botConfig.TenantId, botConfig.ClientId, botConfig.Cloud, botConfig.FicClientId); } else { bool isSystemAssigned = IsSystemAssignedManagedIdentity(botConfig.FicClientId); _logUsingFIC(logger, isSystemAssigned ? "System-Assigned" : "User-Assigned", null); - services.ConfigureMSALWithFIC(botConfig.TenantId, botConfig.ClientId, botConfig.FicClientId); + services.ConfigureMSALWithFIC(botConfig.TenantId, botConfig.ClientId, botConfig.FicClientId, botConfig.Cloud); } return services; } diff --git a/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs index 6a0f35b0..fc198af1 100644 --- a/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs +++ b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Teams.Bot.Core.Hosting; using Microsoft.Teams.Bot.Core.Http; using Microsoft.Teams.Bot.Core.Schema; @@ -15,8 +16,10 @@ namespace Microsoft.Teams.Bot.Core; /// /// /// This client provides methods for OAuth token management including retrieving tokens, exchanging tokens, -/// signing out users, and managing AAD tokens. The client communicates with the Bot Framework Token Service -/// API endpoint (defaults to https://token.botframework.com but can be configured via UserTokenApiEndpoint). +/// signing out users, and managing AAD tokens. The token service base URL is resolved (in priority order) +/// from UserTokenApiEndpoint, from the TokenServiceUrl override (under AzureAd or root), +/// or from the cloud preset chosen by AzureAd:Cloud / Cloud / CLOUD. Defaults to +/// 's token service if nothing is configured. /// /// The HTTP client for making requests to the token service. /// Configuration containing the UserTokenApiEndpoint setting and other bot configuration. @@ -25,9 +28,32 @@ public class UserTokenClient(HttpClient httpClient, IConfiguration configuration { internal const string UserTokenHttpClientName = "BotUserTokenClient"; private readonly BotHttpClient _botHttpClient = new(httpClient, logger); - private readonly string _apiEndpoint = configuration["UserTokenApiEndpoint"] ?? "https://token.botframework.com"; + private readonly string _apiEndpoint = ResolveApiEndpoint(configuration); private readonly JsonSerializerOptions _defaultOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + internal static string ResolveApiEndpoint(IConfiguration configuration) + { + // Explicit override: preserved for backward compatibility. + string? explicitEndpoint = configuration["UserTokenApiEndpoint"]; + if (!string.IsNullOrWhiteSpace(explicitEndpoint)) + { + return explicitEndpoint; + } + + string? cloudName = configuration["AzureAd:Cloud"] + ?? configuration["Cloud"] + ?? configuration["CLOUD"]; + CloudEnvironment baseCloud = string.IsNullOrWhiteSpace(cloudName) + ? CloudEnvironment.Public + : CloudEnvironment.FromName(cloudName); + + // Apply per-endpoint override (section-scoped wins over root), matching BotConfig.ResolveCloud. + string? tokenServiceUrlOverride = configuration["AzureAd:TokenServiceUrl"] + ?? configuration["TokenServiceUrl"]; + + return baseCloud.WithOverrides(tokenServiceUrl: tokenServiceUrlOverride).TokenServiceUrl; + } + internal AgenticIdentity? AgenticIdentity { get; set; } /// diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs index 7c30a685..cba165a9 100644 --- a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/AddBotApplicationExtensionsTests.cs @@ -323,4 +323,250 @@ public void AddBotApplication_ClientIdTakesPrecedenceOverMicrosoftAppId() // Assert Assert.Equal("core-client-id", GetAppId(serviceProvider)); } + + // --- MSAL Instance / Authority validation + Cloud fallback --- + + [Fact] + public void AddConversationClient_SectionInstanceMatchesCloud_Succeeds() + { + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:TenantId"] = "tid", + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:Instance"] = "https://login.microsoftonline.us/" + }; + + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + AssertMsalOptions(serviceProvider, "cid", "tid", "https://login.microsoftonline.us/"); + } + + [Fact] + public void AddConversationClient_SectionInstanceConflictsWithCloud_Throws() + { + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:TenantId"] = "tid", + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:Instance"] = "https://login.microsoftonline.com/" + }; + + InvalidOperationException ex = Assert.Throws( + () => BuildServiceProvider(configData)); + Assert.Contains("AzureAd:Instance", ex.Message); + Assert.Contains("https://login.microsoftonline.com/", ex.Message); + Assert.Contains("https://login.microsoftonline.us", ex.Message); + } + + [Fact] + public void AddConversationClient_SectionInstanceMissing_FallsBackToCloud() + { + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:TenantId"] = "tid", + ["AzureAd:Cloud"] = "USGov" + }; + + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + AssertMsalOptions(serviceProvider, "cid", "tid", "https://login.microsoftonline.us/"); + } + + [Fact] + public void AddConversationClient_SectionAuthorityMatchesCloud_Succeeds() + { + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:TenantId"] = "tid", + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:Authority"] = "https://login.microsoftonline.us/tid/v2.0" + }; + + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + MicrosoftIdentityApplicationOptions msalOptions = serviceProvider + .GetRequiredService>() + .Get(MsalConfigurationExtensions.MsalConfigKey); + Assert.Equal("cid", msalOptions.ClientId); + } + + [Fact] + public void AddConversationClient_SectionAuthorityConflictsWithCloud_Throws() + { + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:TenantId"] = "tid", + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:Authority"] = "https://login.microsoftonline.com/tid/v2.0" + }; + + InvalidOperationException ex = Assert.Throws( + () => BuildServiceProvider(configData)); + Assert.Contains("AzureAd:Authority", ex.Message); + Assert.Contains("https://login.microsoftonline.com/tid/v2.0", ex.Message); + } + + [Fact] + public void AddConversationClient_DefaultCloud_InstanceDefaultsToPublic() + { + Dictionary configData = new() + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:TenantId"] = "tid" + }; + + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + AssertMsalOptions(serviceProvider, "cid", "tid", "https://login.microsoftonline.com/"); + } + + [Fact] + public void AddConversationClient_RawCredentials_ConfiguresCloudAwareInstance() + { + // Core-config path (no MSAL section) should set MSAL Instance from Cloud. + Dictionary configData = new() + { + ["CLIENT_ID"] = "cid", + ["TENANT_ID"] = "tid", + ["CLIENT_SECRET"] = "secret", + ["CLOUD"] = "USGov" + }; + + ServiceProvider serviceProvider = BuildServiceProvider(configData); + + AssertMsalOptions(serviceProvider, "cid", "tid", "https://login.microsoftonline.us/"); + } + + // --- Per-endpoint override binding (BotConfig.ResolveCloud) --- + + [Fact] + public void BotConfig_FromMsalConfig_AppliesTokenIssuerOverrideFromSection() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:TokenIssuer"] = "https://custom.issuer" + }) + .Build(); + + BotConfig result = BotConfig.FromMsalConfig(config); + + Assert.Equal("https://custom.issuer", result.Cloud.TokenIssuer); + Assert.Equal(CloudEnvironment.USGov.LoginEndpoint, result.Cloud.LoginEndpoint); + } + + [Fact] + public void BotConfig_FromMsalConfig_AppliesMultipleEndpointOverrides() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:TokenIssuer"] = "https://iss", + ["AzureAd:TokenServiceUrl"] = "https://tsu", + ["AzureAd:GraphScope"] = "https://graph.override/.default" + }) + .Build(); + + BotConfig result = BotConfig.FromMsalConfig(config); + + Assert.Equal("https://iss", result.Cloud.TokenIssuer); + Assert.Equal("https://tsu", result.Cloud.TokenServiceUrl); + Assert.Equal("https://graph.override/.default", result.Cloud.GraphScope); + } + + [Fact] + public void BotConfig_FromMsalConfig_SectionOverrideWinsOverRoot() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:TokenIssuer"] = "https://section", + ["TokenIssuer"] = "https://root" + }) + .Build(); + + BotConfig result = BotConfig.FromMsalConfig(config); + + Assert.Equal("https://section", result.Cloud.TokenIssuer); + } + + [Fact] + public void BotConfig_FromMsalConfig_FallsBackToRootOverride() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:Cloud"] = "USGov", + ["TokenIssuer"] = "https://root" + }) + .Build(); + + BotConfig result = BotConfig.FromMsalConfig(config); + + Assert.Equal("https://root", result.Cloud.TokenIssuer); + } + + [Fact] + public void BotConfig_FromMsalConfig_WhitespaceOverrideIgnored() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AzureAd:ClientId"] = "cid", + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:TokenIssuer"] = " " + }) + .Build(); + + BotConfig result = BotConfig.FromMsalConfig(config); + + Assert.Equal(CloudEnvironment.USGov.TokenIssuer, result.Cloud.TokenIssuer); + } + + [Fact] + public void BotConfig_FromBFConfig_AppliesEndpointOverrides() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["MicrosoftAppId"] = "cid", + ["Cloud"] = "USGov", + ["TokenIssuer"] = "https://bf-custom" + }) + .Build(); + + BotConfig result = BotConfig.FromBFConfig(config); + + Assert.Equal("https://bf-custom", result.Cloud.TokenIssuer); + } + + [Fact] + public void BotConfig_FromCoreConfig_AppliesEndpointOverrides() + { + IConfiguration config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["CLIENT_ID"] = "cid", + ["CLOUD"] = "China", + ["TokenIssuer"] = "https://core-custom" + }) + .Build(); + + BotConfig result = BotConfig.FromCoreConfig(config); + + Assert.Equal("https://core-custom", result.Cloud.TokenIssuer); + Assert.Equal(CloudEnvironment.China.LoginEndpoint, result.Cloud.LoginEndpoint); + } } diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/CloudEnvironmentTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/CloudEnvironmentTests.cs new file mode 100644 index 00000000..2493e4d6 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/CloudEnvironmentTests.cs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Core.Hosting; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Hosting; + +public class CloudEnvironmentTests +{ + [Fact] + public void Public_HasCorrectEndpoints() + { + var env = CloudEnvironment.Public; + + Assert.Equal("https://login.microsoftonline.com", env.LoginEndpoint); + Assert.Equal("botframework.com", env.LoginTenant); + Assert.Equal("https://api.botframework.com/.default", env.BotScope); + Assert.Equal("https://token.botframework.com", env.TokenServiceUrl); + Assert.Equal("https://login.botframework.com/v1/.well-known/openidconfiguration", env.OpenIdMetadataUrl); + Assert.Equal("https://api.botframework.com", env.TokenIssuer); + Assert.Equal("https://graph.microsoft.com/.default", env.GraphScope); + } + + [Fact] + public void USGov_HasCorrectEndpoints() + { + var env = CloudEnvironment.USGov; + + Assert.Equal("https://login.microsoftonline.us", env.LoginEndpoint); + Assert.Equal("MicrosoftServices.onmicrosoft.us", env.LoginTenant); + Assert.Equal("https://api.botframework.us/.default", env.BotScope); + Assert.Equal("https://tokengcch.botframework.azure.us", env.TokenServiceUrl); + Assert.Equal("https://login.botframework.azure.us/v1/.well-known/openidconfiguration", env.OpenIdMetadataUrl); + Assert.Equal("https://api.botframework.us", env.TokenIssuer); + Assert.Equal("https://graph.microsoft.us/.default", env.GraphScope); + } + + [Fact] + public void USGovDoD_HasCorrectEndpoints() + { + var env = CloudEnvironment.USGovDoD; + + Assert.Equal("https://login.microsoftonline.us", env.LoginEndpoint); + Assert.Equal("MicrosoftServices.onmicrosoft.us", env.LoginTenant); + Assert.Equal("https://api.botframework.us/.default", env.BotScope); + Assert.Equal("https://apiDoD.botframework.azure.us", env.TokenServiceUrl); + Assert.Equal("https://api.botframework.us", env.TokenIssuer); + Assert.Equal("https://dod-graph.microsoft.us/.default", env.GraphScope); + } + + [Fact] + public void China_HasCorrectEndpoints() + { + var env = CloudEnvironment.China; + + Assert.Equal("https://login.partner.microsoftonline.cn", env.LoginEndpoint); + Assert.Equal("microsoftservices.partner.onmschina.cn", env.LoginTenant); + Assert.Equal("https://api.botframework.azure.cn/.default", env.BotScope); + Assert.Equal("https://token.botframework.azure.cn", env.TokenServiceUrl); + Assert.Equal("https://api.botframework.azure.cn", env.TokenIssuer); + Assert.Equal("https://microsoftgraph.chinacloudapi.cn/.default", env.GraphScope); + } + + [Theory] + [InlineData("Public", "https://login.microsoftonline.com")] + [InlineData("public", "https://login.microsoftonline.com")] + [InlineData("PUBLIC", "https://login.microsoftonline.com")] + [InlineData("USGov", "https://login.microsoftonline.us")] + [InlineData("usgov", "https://login.microsoftonline.us")] + [InlineData("USGovDoD", "https://login.microsoftonline.us")] + [InlineData("usgovdod", "https://login.microsoftonline.us")] + [InlineData("China", "https://login.partner.microsoftonline.cn")] + [InlineData("china", "https://login.partner.microsoftonline.cn")] + public void FromName_ResolvesCorrectly(string name, string expectedLoginEndpoint) + { + var env = CloudEnvironment.FromName(name); + Assert.Equal(expectedLoginEndpoint, env.LoginEndpoint); + } + + [Fact] + public void FromName_ReturnsStaticInstances() + { + Assert.Same(CloudEnvironment.Public, CloudEnvironment.FromName("Public")); + Assert.Same(CloudEnvironment.USGov, CloudEnvironment.FromName("USGov")); + Assert.Same(CloudEnvironment.USGovDoD, CloudEnvironment.FromName("USGovDoD")); + Assert.Same(CloudEnvironment.China, CloudEnvironment.FromName("China")); + } + + [Theory] + [InlineData("invalid")] + [InlineData("Azure")] + public void FromName_ThrowsForUnknownName(string name) + { + Assert.Throws(() => CloudEnvironment.FromName(name)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void FromName_ThrowsForEmptyOrWhitespace(string name) + { + ArgumentException ex = Assert.Throws(() => CloudEnvironment.FromName(name)); + Assert.Contains("empty or whitespace", ex.Message); + } + + [Fact] + public void FromName_ThrowsForNull() + { + Assert.Throws(() => CloudEnvironment.FromName(null!)); + } + + [Fact] + public void Constructor_TrimsTrailingSlashOnLoginEndpointAndTokenServiceUrl() + { + var env = new CloudEnvironment( + loginEndpoint: "https://example.com/", + loginTenant: "tenant", + botScope: "scope", + tokenServiceUrl: "https://token.example.com/", + openIdMetadataUrl: "https://oidc.example.com", + tokenIssuer: "issuer", + graphScope: "graph"); + + Assert.Equal("https://example.com", env.LoginEndpoint); + Assert.Equal("https://token.example.com", env.TokenServiceUrl); + } + + [Fact] + public void WithOverrides_AllNulls_ReturnsSameInstance() + { + var env = CloudEnvironment.Public; + + var result = env.WithOverrides(); + + Assert.Same(env, result); + } + + [Fact] + public void WithOverrides_SingleOverride_ReplacesOnlyThatProperty() + { + var env = CloudEnvironment.Public; + + var result = env.WithOverrides(tokenIssuer: "https://custom.issuer"); + + Assert.NotSame(env, result); + Assert.Equal("https://custom.issuer", result.TokenIssuer); + Assert.Equal(env.LoginEndpoint, result.LoginEndpoint); + Assert.Equal(env.BotScope, result.BotScope); + Assert.Equal(env.TokenServiceUrl, result.TokenServiceUrl); + Assert.Equal(env.OpenIdMetadataUrl, result.OpenIdMetadataUrl); + Assert.Equal(env.GraphScope, result.GraphScope); + } + + [Fact] + public void WithOverrides_AllOverrides_ReplacesAllProperties() + { + var env = CloudEnvironment.Public; + + var result = env.WithOverrides( + loginEndpoint: "https://a", + loginTenant: "b", + botScope: "c", + tokenServiceUrl: "https://d", + openIdMetadataUrl: "e", + tokenIssuer: "f", + graphScope: "g"); + + Assert.Equal("https://a", result.LoginEndpoint); + Assert.Equal("b", result.LoginTenant); + Assert.Equal("c", result.BotScope); + Assert.Equal("https://d", result.TokenServiceUrl); + Assert.Equal("e", result.OpenIdMetadataUrl); + Assert.Equal("f", result.TokenIssuer); + Assert.Equal("g", result.GraphScope); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/JwtExtensionsTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/JwtExtensionsTests.cs new file mode 100644 index 00000000..64e56ed0 --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/Hosting/JwtExtensionsTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Teams.Bot.Core.Hosting; + +namespace Microsoft.Teams.Bot.Core.UnitTests.Hosting; + +public class JwtExtensionsTests +{ + private const string Tenant = "00000000-0000-0000-0000-000000000001"; + + private static SecurityToken MakeToken(string? tid = null) + { + JwtSecurityTokenHandler handler = new(); + JwtSecurityToken jwt = new( + claims: tid is null ? [] : [new Claim("tid", tid)]); + return new JsonWebToken(handler.WriteToken(jwt)); + } + + [Fact] + public void ValidateTeamsIssuer_AcceptsBotFrameworkIssuerForPublic() + { + string result = JwtExtensions.ValidateTeamsIssuer( + "https://api.botframework.com", + MakeToken(), + configuredTenantId: "", + CloudEnvironment.Public); + + Assert.Equal("https://api.botframework.com", result); + } + + [Fact] + public void ValidateTeamsIssuer_AcceptsBotFrameworkIssuerForUSGov() + { + string result = JwtExtensions.ValidateTeamsIssuer( + "https://api.botframework.us", + MakeToken(), + configuredTenantId: "", + CloudEnvironment.USGov); + + Assert.Equal("https://api.botframework.us", result); + } + + [Fact] + public void ValidateTeamsIssuer_RejectsPublicBotIssuerWhenCloudIsUSGov() + { + Assert.Throws(() => + JwtExtensions.ValidateTeamsIssuer( + "https://api.botframework.com", + MakeToken(), + configuredTenantId: "", + CloudEnvironment.USGov)); + } + + [Fact] + public void ValidateTeamsIssuer_AcceptsEntraV2IssuerForUSGov() + { + string result = JwtExtensions.ValidateTeamsIssuer( + $"https://login.microsoftonline.us/{Tenant}/v2.0", + MakeToken(tid: Tenant), + configuredTenantId: Tenant, + CloudEnvironment.USGov); + + Assert.Equal($"https://login.microsoftonline.us/{Tenant}/v2.0", result); + } + + [Fact] + public void ValidateTeamsIssuer_RejectsPublicEntraV2IssuerWhenCloudIsUSGov() + { + Assert.Throws(() => + JwtExtensions.ValidateTeamsIssuer( + $"https://login.microsoftonline.com/{Tenant}/v2.0", + MakeToken(tid: Tenant), + configuredTenantId: Tenant, + CloudEnvironment.USGov)); + } + + [Fact] + public void ValidateTeamsIssuer_AcceptsEntraV2IssuerForChina() + { + string result = JwtExtensions.ValidateTeamsIssuer( + $"https://login.partner.microsoftonline.cn/{Tenant}/v2.0", + MakeToken(tid: Tenant), + configuredTenantId: Tenant, + CloudEnvironment.China); + + Assert.Equal($"https://login.partner.microsoftonline.cn/{Tenant}/v2.0", result); + } + + [Fact] + public void ValidateTeamsIssuer_UsesTokenTidWhenConfiguredTenantEmpty() + { + string result = JwtExtensions.ValidateTeamsIssuer( + $"https://login.microsoftonline.com/{Tenant}/v2.0", + MakeToken(tid: Tenant), + configuredTenantId: "", + CloudEnvironment.Public); + + Assert.Equal($"https://login.microsoftonline.com/{Tenant}/v2.0", result); + } + + [Fact] + public void ValidateTeamsIssuer_AcceptsStsV1IssuerForPublic() + { + // v1.0 STS issuer: kept as hardcoded sts.windows.net. This is a known limitation + // for sovereign clouds that use a different STS (e.g. China: sts.chinacloudapi.cn). + string result = JwtExtensions.ValidateTeamsIssuer( + $"https://sts.windows.net/{Tenant}/", + MakeToken(tid: Tenant), + configuredTenantId: Tenant, + CloudEnvironment.Public); + + Assert.Equal($"https://sts.windows.net/{Tenant}/", result); + } + + [Fact] + public void ValidateTeamsIssuer_RejectsUnknownIssuer() + { + Assert.Throws(() => + JwtExtensions.ValidateTeamsIssuer( + "https://evil.example.com", + MakeToken(tid: Tenant), + configuredTenantId: Tenant, + CloudEnvironment.Public)); + } +} diff --git a/core/test/Microsoft.Teams.Bot.Core.UnitTests/UserTokenClientTests.cs b/core/test/Microsoft.Teams.Bot.Core.UnitTests/UserTokenClientTests.cs new file mode 100644 index 00000000..a7d7c5ef --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Core.UnitTests/UserTokenClientTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Configuration; +using Microsoft.Teams.Bot.Core; + +namespace Microsoft.Teams.Bot.Core.UnitTests; + +public class UserTokenClientTests +{ + private static IConfiguration Config(Dictionary data) => + new ConfigurationBuilder().AddInMemoryCollection(data).Build(); + + [Fact] + public void ResolveApiEndpoint_NoConfiguration_DefaultsToPublicTokenService() + { + Assert.Equal("https://token.botframework.com", UserTokenClient.ResolveApiEndpoint(Config([]))); + } + + [Fact] + public void ResolveApiEndpoint_ExplicitUserTokenApiEndpoint_WinsOverEverything() + { + IConfiguration config = Config(new() + { + ["UserTokenApiEndpoint"] = "https://my.explicit.endpoint", + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:TokenServiceUrl"] = "https://should-be-ignored" + }); + + Assert.Equal("https://my.explicit.endpoint", UserTokenClient.ResolveApiEndpoint(config)); + } + + [Theory] + [InlineData("USGov", "https://tokengcch.botframework.azure.us")] + [InlineData("USGovDoD", "https://apiDoD.botframework.azure.us")] + [InlineData("China", "https://token.botframework.azure.cn")] + [InlineData("Public", "https://token.botframework.com")] + public void ResolveApiEndpoint_CloudPresetAzureAdSection_ResolvesSovereignEndpoint(string cloudName, string expected) + { + IConfiguration config = Config(new() { ["AzureAd:Cloud"] = cloudName }); + Assert.Equal(expected, UserTokenClient.ResolveApiEndpoint(config)); + } + + [Fact] + public void ResolveApiEndpoint_RootCloudKey_Works() + { + Assert.Equal("https://tokengcch.botframework.azure.us", + UserTokenClient.ResolveApiEndpoint(Config(new() { ["Cloud"] = "USGov" }))); + } + + [Fact] + public void ResolveApiEndpoint_UppercaseCloudKey_Works() + { + Assert.Equal("https://token.botframework.azure.cn", + UserTokenClient.ResolveApiEndpoint(Config(new() { ["CLOUD"] = "China" }))); + } + + [Fact] + public void ResolveApiEndpoint_SectionTokenServiceUrlOverride_AppliesOnTopOfCloud() + { + IConfiguration config = Config(new() + { + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:TokenServiceUrl"] = "https://custom.token.service" + }); + + Assert.Equal("https://custom.token.service", UserTokenClient.ResolveApiEndpoint(config)); + } + + [Fact] + public void ResolveApiEndpoint_RootTokenServiceUrlOverride_AppliesWhenNoCloudSet() + { + IConfiguration config = Config(new() { ["TokenServiceUrl"] = "https://root-override" }); + + Assert.Equal("https://root-override", UserTokenClient.ResolveApiEndpoint(config)); + } + + [Fact] + public void ResolveApiEndpoint_SectionOverrideBeatsRootOverride() + { + IConfiguration config = Config(new() + { + ["AzureAd:TokenServiceUrl"] = "https://section", + ["TokenServiceUrl"] = "https://root" + }); + + Assert.Equal("https://section", UserTokenClient.ResolveApiEndpoint(config)); + } + + [Fact] + public void ResolveApiEndpoint_WhitespaceOverrideIgnored_FallsBackToCloud() + { + IConfiguration config = Config(new() + { + ["AzureAd:Cloud"] = "USGov", + ["AzureAd:TokenServiceUrl"] = " " + }); + + Assert.Equal("https://tokengcch.botframework.azure.us", UserTokenClient.ResolveApiEndpoint(config)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ResolveApiEndpoint_EmptyOrWhitespaceCloud_DefaultsToPublic(string cloudValue) + { + IConfiguration config = Config(new() { ["AzureAd:Cloud"] = cloudValue }); + Assert.Equal("https://token.botframework.com", UserTokenClient.ResolveApiEndpoint(config)); + } +}