From 29d6075ab97541c68e659e2d0e6ed9483c2975b2 Mon Sep 17 00:00:00 2001 From: Rajan Date: Thu, 26 Feb 2026 11:30:23 -0500 Subject: [PATCH 1/5] Add sovereign cloud support (GCCH, DoD, China) Introduce CloudEnvironment class that bundles all cloud-specific service endpoints, with predefined instances for Public, USGov (GCCH), USGovDoD, and China (21Vianet). Thread the cloud environment through ClientCredentials, token clients, validation settings, and DI host builders so that all previously hardcoded endpoints are now configurable per cloud. Co-Authored-By: Claude Opus 4.6 --- .../Auth/ClientCredentials.cs | 5 +- .../Auth/CloudEnvironment.cs | 142 ++++++++++++++++++ .../Clients/BotSignInClient.cs | 6 +- .../Clients/BotTokenClient.cs | 3 +- .../Clients/UserTokenClient.cs | 12 +- Libraries/Microsoft.Teams.Apps/App.cs | 7 +- Libraries/Microsoft.Teams.Apps/AppOptions.cs | 2 + .../TeamsSettings.cs | 14 +- .../HostApplicationBuilder.cs | 29 +++- .../Extensions/HostApplicationBuilder.cs | 4 +- .../Extensions/TeamsValidationSettings.cs | 24 ++- .../Auth/CloudEnvironmentTests.cs | 103 +++++++++++++ 12 files changed, 330 insertions(+), 21 deletions(-) create mode 100644 Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs create mode 100644 Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs diff --git a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs index 7ae6d974..e198eb73 100644 --- a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs +++ b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs @@ -10,6 +10,7 @@ public class ClientCredentials : IHttpCredentials public string ClientId { get; set; } public string ClientSecret { get; set; } public string? TenantId { get; set; } + public CloudEnvironment Cloud { get; set; } = CloudEnvironment.Public; public ClientCredentials(string clientId, string clientSecret) { @@ -26,9 +27,9 @@ public ClientCredentials(string clientId, string clientSecret, string? tenantId) public async Task Resolve(IHttpClient client, string[] scopes, CancellationToken cancellationToken = default) { - var tenantId = TenantId ?? "botframework.com"; + var tenantId = TenantId ?? Cloud.LoginTenant; var request = HttpRequest.Post( - $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token" + $"{Cloud.LoginEndpoint}/{tenantId}/oauth2/v2.0/token" ); request.Headers.Add("Content-Type", ["application/x-www-form-urlencoded"]); diff --git a/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs b/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs new file mode 100644 index 00000000..e96aeec5 --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +namespace Microsoft.Teams.Api.Auth; + +/// +/// Bundles all cloud-specific service endpoints for a given Azure environment. +/// Use predefined instances (, , , ) +/// or construct a custom one. +/// +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 (e.g. "https://login.botframework.com/v1/.well-known/openidconfiguration"). + /// + public string OpenIdMetadataUrl { get; } + + /// + /// The token issuer for Bot Framework tokens (e.g. "https://api.botframework.com"). + /// + public string TokenIssuer { get; } + + /// + /// The channel service URL. Empty for public cloud; set for sovereign clouds + /// (e.g. "https://botframework.azure.us"). + /// + public string ChannelService { get; } + + /// + /// The OAuth redirect URL (e.g. "https://token.botframework.com/.auth/web/redirect"). + /// + public string OAuthRedirectUrl { get; } + + public CloudEnvironment( + string loginEndpoint, + string loginTenant, + string botScope, + string tokenServiceUrl, + string openIdMetadataUrl, + string tokenIssuer, + string channelService, + string oauthRedirectUrl) + { + LoginEndpoint = loginEndpoint; + LoginTenant = loginTenant; + BotScope = botScope; + TokenServiceUrl = tokenServiceUrl; + OpenIdMetadataUrl = openIdMetadataUrl; + TokenIssuer = tokenIssuer; + ChannelService = channelService; + OAuthRedirectUrl = oauthRedirectUrl; + } + + /// + /// 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", + channelService: "", + oauthRedirectUrl: "https://token.botframework.com/.auth/web/redirect" + ); + + /// + /// 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", + channelService: "https://botframework.azure.us", + oauthRedirectUrl: "https://tokengcch.botframework.azure.us/.auth/web/redirect" + ); + + /// + /// 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", + channelService: "https://botframework.azure.us", + oauthRedirectUrl: "https://apiDoD.botframework.azure.us/.auth/web/redirect" + ); + + /// + /// 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", + channelService: "https://botframework.azure.cn", + oauthRedirectUrl: "https://token.botframework.azure.cn/.auth/web/redirect" + ); + + /// + /// Resolves a cloud environment name (case-insensitive) to its corresponding instance. + /// Valid names: "Public", "USGov", "USGovDoD", "China". + /// + public static CloudEnvironment FromName(string name) => name.ToLowerInvariant() 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/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs index 52217361..cf1e1f8f 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs @@ -7,6 +7,8 @@ namespace Microsoft.Teams.Api.Clients; public class BotSignInClient : Client { + public string TokenServiceUrl { get; set; } = "https://token.botframework.com"; + public BotSignInClient() : base() { @@ -31,7 +33,7 @@ public async Task GetUrlAsync(GetUrlRequest request) { var query = QueryString.Serialize(request); var req = HttpRequest.Get( - $"https://token.botframework.com/api/botsignin/GetSignInUrl?{query}" + $"{TokenServiceUrl}/api/botsignin/GetSignInUrl?{query}" ); var res = await _http.SendAsync(req, _cancellationToken); @@ -42,7 +44,7 @@ public async Task GetUrlAsync(GetUrlRequest request) { var query = QueryString.Serialize(request); var req = HttpRequest.Get( - $"https://token.botframework.com/api/botsignin/GetSignInResource?{query}" + $"{TokenServiceUrl}/api/botsignin/GetSignInResource?{query}" ); var res = await _http.SendAsync(req, _cancellationToken); diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs index 8255d89c..505144ba 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs @@ -7,8 +7,9 @@ namespace Microsoft.Teams.Api.Clients; public class BotTokenClient : Client { - public static readonly string BotScope = "https://api.botframework.com/.default"; + public static readonly string DefaultBotScope = "https://api.botframework.com/.default"; public static readonly string GraphScope = "https://graph.microsoft.com/.default"; + public string BotScope { get; set; } = DefaultBotScope; public BotTokenClient() : this(default) { diff --git a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs index cf264d6a..e2642629 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs @@ -10,6 +10,8 @@ namespace Microsoft.Teams.Api.Clients; public class UserTokenClient : Client { + public string TokenServiceUrl { get; set; } = "https://token.botframework.com"; + private readonly JsonSerializerOptions _jsonSerializerOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull @@ -38,7 +40,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio public async Task GetAsync(GetTokenRequest request) { var query = QueryString.Serialize(request); - var req = HttpRequest.Get($"https://token.botframework.com/api/usertoken/GetToken?{query}"); + var req = HttpRequest.Get($"{TokenServiceUrl}/api/usertoken/GetToken?{query}"); var res = await _http.SendAsync(req, _cancellationToken); return res.Body; } @@ -46,7 +48,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio public async Task> GetAadAsync(GetAadTokenRequest request) { var query = QueryString.Serialize(request); - var req = HttpRequest.Post($"https://token.botframework.com/api/usertoken/GetAadTokens?{query}", body: request); + var req = HttpRequest.Post($"{TokenServiceUrl}/api/usertoken/GetAadTokens?{query}", body: request); var res = await _http.SendAsync>(req, _cancellationToken); return res.Body; } @@ -54,7 +56,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio public async Task> GetStatusAsync(GetTokenStatusRequest request) { var query = QueryString.Serialize(request); - var req = HttpRequest.Get($"https://token.botframework.com/api/usertoken/GetTokenStatus?{query}"); + var req = HttpRequest.Get($"{TokenServiceUrl}/api/usertoken/GetTokenStatus?{query}"); var res = await _http.SendAsync>(req, _cancellationToken); return res.Body; } @@ -62,7 +64,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio public async Task SignOutAsync(SignOutRequest request) { var query = QueryString.Serialize(request); - var req = HttpRequest.Delete($"https://token.botframework.com/api/usertoken/SignOut?{query}"); + var req = HttpRequest.Delete($"{TokenServiceUrl}/api/usertoken/SignOut?{query}"); await _http.SendAsync(req, _cancellationToken); } @@ -79,7 +81,7 @@ public async Task SignOutAsync(SignOutRequest request) // This is required for the Bot Framework Token Service to process the request correctly. var body = JsonSerializer.Serialize(request.GetBody(), _jsonSerializerOptions); - var req = HttpRequest.Post($"https://token.botframework.com/api/usertoken/exchange?{query}", body); + var req = HttpRequest.Post($"{TokenServiceUrl}/api/usertoken/exchange?{query}", body); req.Headers.Add("Content-Type", new List() { "application/json" }); var res = await _http.SendAsync(req, _cancellationToken); diff --git a/Libraries/Microsoft.Teams.Apps/App.cs b/Libraries/Microsoft.Teams.Apps/App.cs index a4b1c62f..1a783af1 100644 --- a/Libraries/Microsoft.Teams.Apps/App.cs +++ b/Libraries/Microsoft.Teams.Apps/App.cs @@ -51,6 +51,8 @@ internal string UserAgent public App(AppOptions? options = null) { + var cloud = options?.Cloud ?? CloudEnvironment.Public; + Logger = options?.Logger ?? new ConsoleLogger(); Storage = options?.Storage ?? new LocalStorage(); Credentials = options?.Credentials; @@ -77,7 +79,7 @@ public App(AppOptions? options = null) if (Token.IsExpired) { - var res = Credentials.Resolve(TokenClient, [.. Token.Scopes.DefaultIfEmpty(BotTokenClient.BotScope)]) + var res = Credentials.Resolve(TokenClient, [.. Token.Scopes.DefaultIfEmpty(cloud.BotScope)]) .ConfigureAwait(false) .GetAwaiter() .GetResult(); @@ -90,6 +92,9 @@ public App(AppOptions? options = null) }; Api = new ApiClient("https://smba.trafficmanager.net/teams/", Client); + Api.Bots.Token.BotScope = cloud.BotScope; + Api.Bots.SignIn.TokenServiceUrl = cloud.TokenServiceUrl; + Api.Users.Token.TokenServiceUrl = cloud.TokenServiceUrl; Container = new Container(); Container.Register(Logger); Container.Register(Storage); diff --git a/Libraries/Microsoft.Teams.Apps/AppOptions.cs b/Libraries/Microsoft.Teams.Apps/AppOptions.cs index b923afa2..766016bc 100644 --- a/Libraries/Microsoft.Teams.Apps/AppOptions.cs +++ b/Libraries/Microsoft.Teams.Apps/AppOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +using Microsoft.Teams.Api.Auth; using Microsoft.Teams.Apps.Plugins; namespace Microsoft.Teams.Apps; @@ -15,6 +16,7 @@ public class AppOptions public Common.Http.IHttpCredentials? Credentials { get; set; } public IList Plugins { get; set; } = []; public OAuthSettings OAuth { get; set; } = new OAuthSettings(); + public CloudEnvironment? Cloud { get; set; } public AppOptions() { diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index 11a4e532..b0d66b2a 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -10,6 +10,7 @@ public class TeamsSettings public string? ClientId { get; set; } public string? ClientSecret { get; set; } public string? TenantId { get; set; } + public string? Cloud { get; set; } public bool Empty { @@ -20,9 +21,20 @@ public AppOptions Apply(AppOptions? options = null) { options ??= new AppOptions(); + if (Cloud is not null) + { + options.Cloud = CloudEnvironment.FromName(Cloud); + } + + var cloud = options.Cloud ?? CloudEnvironment.Public; + if (ClientId is not null && ClientSecret is not null && !Empty) { - options.Credentials = new ClientCredentials(ClientId, ClientSecret, TenantId); + var credentials = new ClientCredentials(ClientId, ClientSecret, TenantId) + { + Cloud = cloud + }; + options.Credentials = credentials; } return options; diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs index 01f28920..05bd1789 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs @@ -31,6 +31,14 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder var settings = builder.Configuration.GetTeams(); var loggingSettings = builder.Configuration.GetTeamsLogging(); + // cloud environment + if (settings.Cloud is not null && options.Cloud is null) + { + options.Cloud = CloudEnvironment.FromName(settings.Cloud); + } + + var cloud = options.Cloud ?? CloudEnvironment.Public; + // client credentials if (options.Credentials is null && settings.ClientId is not null && settings.ClientSecret is not null && !settings.Empty) { @@ -38,7 +46,8 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder settings.ClientId, settings.ClientSecret, settings.TenantId - ); + ) + { Cloud = cloud }; } options.Logger ??= new ConsoleLogger(loggingSettings); @@ -56,14 +65,28 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder var settings = builder.Configuration.GetTeams(); var loggingSettings = builder.Configuration.GetTeamsLogging(); + // cloud environment + CloudEnvironment? cloud = null; + if (settings.Cloud is not null) + { + cloud = CloudEnvironment.FromName(settings.Cloud); + } + // client credentials if (settings.ClientId is not null && settings.ClientSecret is not null && !settings.Empty) { - appBuilder = appBuilder.AddCredentials(new ClientCredentials( + var credentials = new ClientCredentials( settings.ClientId, settings.ClientSecret, settings.TenantId - )); + ); + + if (cloud is not null) + { + credentials.Cloud = cloud; + } + + appBuilder = appBuilder.AddCredentials(credentials); } var app = appBuilder.Build(); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs index 760d94da..e2efbe0b 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Teams.Api.Auth; using Microsoft.Teams.Apps; using Microsoft.Teams.Apps.Extensions; @@ -119,8 +120,9 @@ public static class EntraTokenAuthConstants public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostApplicationBuilder builder, bool skipAuth = false) { var settings = builder.Configuration.GetTeams(); + var cloud = settings.Cloud is not null ? CloudEnvironment.FromName(settings.Cloud) : CloudEnvironment.Public; - var teamsValidationSettings = new TeamsValidationSettings(); + var teamsValidationSettings = new TeamsValidationSettings(cloud); if (!string.IsNullOrEmpty(settings.ClientId)) { teamsValidationSettings.AddDefaultAudiences(settings.ClientId); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs index 5443c928..f7bf4b7f 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs @@ -1,11 +1,24 @@ +using Microsoft.Teams.Api.Auth; + namespace Microsoft.Teams.Plugins.AspNetCore.Extensions; public class TeamsValidationSettings { - public string OpenIdMetadataUrl = "https://login.botframework.com/v1/.well-known/openidconfiguration"; + public string OpenIdMetadataUrl; public List Audiences = []; - public List Issuers = [ - "https://api.botframework.com", + public List Issuers; + public string LoginEndpoint; + + public TeamsValidationSettings() : this(CloudEnvironment.Public) + { + } + + public TeamsValidationSettings(CloudEnvironment cloud) + { + LoginEndpoint = cloud.LoginEndpoint; + OpenIdMetadataUrl = cloud.OpenIdMetadataUrl; + Issuers = [ + cloud.TokenIssuer, "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", // Emulator Auth v3.1, 1.0 token "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", // Emulator Auth v3.1, 2.0 token "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", // Emulator Auth v3.2, 1.0 token @@ -13,6 +26,7 @@ public class TeamsValidationSettings "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", // Copilot Auth v1.0 token "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", // Copilot Auth v2.0 token ]; + } public void AddDefaultAudiences(string ClientId) { @@ -29,13 +43,13 @@ public IEnumerable GetValidIssuersForTenant(string? tenantId) var validIssuers = new List(); if (!string.IsNullOrEmpty(tenantId)) { - validIssuers.Add($"https://login.microsoftonline.com/{tenantId}/"); + validIssuers.Add($"{LoginEndpoint}/{tenantId}/"); } return validIssuers; } public string GetTenantSpecificOpenIdMetadataUrl(string? tenantId) { - return $"https://login.microsoftonline.com/{tenantId ?? "common"}/v2.0/.well-known/openid-configuration"; + return $"{LoginEndpoint}/{tenantId ?? "common"}/v2.0/.well-known/openid-configuration"; } } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs new file mode 100644 index 00000000..afc2dc21 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Teams.Api.Auth; + +namespace Microsoft.Teams.Api.Tests.Auth; + +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("", env.ChannelService); + Assert.Equal("https://token.botframework.com/.auth/web/redirect", env.OAuthRedirectUrl); + } + + [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://botframework.azure.us", env.ChannelService); + Assert.Equal("https://tokengcch.botframework.azure.us/.auth/web/redirect", env.OAuthRedirectUrl); + } + + [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://login.botframework.azure.us/v1/.well-known/openidconfiguration", env.OpenIdMetadataUrl); + Assert.Equal("https://api.botframework.us", env.TokenIssuer); + Assert.Equal("https://botframework.azure.us", env.ChannelService); + Assert.Equal("https://apiDoD.botframework.azure.us/.auth/web/redirect", env.OAuthRedirectUrl); + } + + [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://login.botframework.azure.cn/v1/.well-known/openidconfiguration", env.OpenIdMetadataUrl); + Assert.Equal("https://api.botframework.azure.cn", env.TokenIssuer); + Assert.Equal("https://botframework.azure.cn", env.ChannelService); + Assert.Equal("https://token.botframework.azure.cn/.auth/web/redirect", env.OAuthRedirectUrl); + } + + [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); + } + + [Theory] + [InlineData("invalid")] + [InlineData("")] + [InlineData("Azure")] + public void FromName_ThrowsForUnknownName(string name) + { + Assert.Throws(() => CloudEnvironment.FromName(name)); + } + + [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")); + } +} From 477de27db0d0a3c25284dbcc13ba04c359e488df Mon Sep 17 00:00:00 2001 From: Rajan Date: Thu, 26 Feb 2026 14:07:08 -0500 Subject: [PATCH 2/5] Add individual cloud endpoint overrides to TeamsSettings Allow users to override specific CloudEnvironment endpoints (e.g. LoginEndpoint, LoginTenant) via appsettings.json, enabling scenarios like China single-tenant bots that require a tenant-specific login URL. - Add CloudEnvironment.WithOverrides() for layering nullable overrides - Add 8 endpoint override properties + ResolveCloud() helper to TeamsSettings - Unify cloud resolution across Apply(), AddTeamsCore(), and AddTeamsTokenAuthentication() - Add WithOverrides unit tests Co-Authored-By: Claude Opus 4.6 --- .../Auth/CloudEnvironment.cs | 33 ++++++++ .../TeamsSettings.cs | 55 ++++++++++++-- .../HostApplicationBuilder.cs | 26 ++----- .../Extensions/HostApplicationBuilder.cs | 2 +- .../Auth/CloudEnvironmentTests.cs | 76 +++++++++++++++++++ 5 files changed, 166 insertions(+), 26 deletions(-) diff --git a/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs b/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs index e96aeec5..7910727a 100644 --- a/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs +++ b/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs @@ -127,6 +127,39 @@ public CloudEnvironment( oauthRedirectUrl: "https://token.botframework.azure.cn/.auth/web/redirect" ); + /// + /// Creates a new by applying non-null overrides on top of this instance. + /// Returns the same instance if all overrides are null (no allocation). + /// + public CloudEnvironment WithOverrides( + string? loginEndpoint = null, + string? loginTenant = null, + string? botScope = null, + string? tokenServiceUrl = null, + string? openIdMetadataUrl = null, + string? tokenIssuer = null, + string? channelService = null, + string? oauthRedirectUrl = null) + { + if (loginEndpoint is null && loginTenant is null && botScope is null && + tokenServiceUrl is null && openIdMetadataUrl is null && tokenIssuer is null && + channelService is null && oauthRedirectUrl is null) + { + return this; + } + + return new CloudEnvironment( + loginEndpoint ?? LoginEndpoint, + loginTenant ?? LoginTenant, + botScope ?? BotScope, + tokenServiceUrl ?? TokenServiceUrl, + openIdMetadataUrl ?? OpenIdMetadataUrl, + tokenIssuer ?? TokenIssuer, + channelService ?? ChannelService, + oauthRedirectUrl ?? OAuthRedirectUrl + ); + } + /// /// Resolves a cloud environment name (case-insensitive) to its corresponding instance. /// Valid names: "Public", "USGov", "USGovDoD", "China". diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs index b0d66b2a..46efca80 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs @@ -12,21 +12,64 @@ public class TeamsSettings public string? TenantId { get; set; } public string? Cloud { get; set; } + /// Override the Azure AD login endpoint. + public string? LoginEndpoint { get; set; } + + /// Override the default login tenant. + public string? LoginTenant { get; set; } + + /// Override the Bot Framework OAuth scope. + public string? BotScope { get; set; } + + /// Override the Bot Framework token service URL. + public string? TokenServiceUrl { get; set; } + + /// Override the OpenID metadata URL for token validation. + public string? OpenIdMetadataUrl { get; set; } + + /// Override the token issuer for Bot Framework tokens. + public string? TokenIssuer { get; set; } + + /// Override the channel service URL. + public string? ChannelService { get; set; } + + /// Override the OAuth redirect URL. + public string? OAuthRedirectUrl { get; set; } + public bool Empty { get { return ClientId == "" || ClientSecret == ""; } } + /// + /// Resolves the by starting from + /// (or the setting, or ), then applying + /// any per-endpoint overrides from settings. + /// + public CloudEnvironment ResolveCloud(CloudEnvironment? programmaticCloud = null) + { + var baseCloud = programmaticCloud + ?? (Cloud is not null ? CloudEnvironment.FromName(Cloud) : null) + ?? CloudEnvironment.Public; + + return baseCloud.WithOverrides( + loginEndpoint: LoginEndpoint, + loginTenant: LoginTenant, + botScope: BotScope, + tokenServiceUrl: TokenServiceUrl, + openIdMetadataUrl: OpenIdMetadataUrl, + tokenIssuer: TokenIssuer, + channelService: ChannelService, + oauthRedirectUrl: OAuthRedirectUrl + ); + } + public AppOptions Apply(AppOptions? options = null) { options ??= new AppOptions(); - if (Cloud is not null) - { - options.Cloud = CloudEnvironment.FromName(Cloud); - } - - var cloud = options.Cloud ?? CloudEnvironment.Public; + var cloud = ResolveCloud(options.Cloud); + options.Cloud = cloud; if (ClientId is not null && ClientSecret is not null && !Empty) { diff --git a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs index 05bd1789..2c0342d6 100644 --- a/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Extensions/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs @@ -31,13 +31,9 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder var settings = builder.Configuration.GetTeams(); var loggingSettings = builder.Configuration.GetTeamsLogging(); - // cloud environment - if (settings.Cloud is not null && options.Cloud is null) - { - options.Cloud = CloudEnvironment.FromName(settings.Cloud); - } - - var cloud = options.Cloud ?? CloudEnvironment.Public; + // cloud environment (base preset + per-endpoint overrides) + var cloud = settings.ResolveCloud(options.Cloud); + options.Cloud = cloud; // client credentials if (options.Credentials is null && settings.ClientId is not null && settings.ClientSecret is not null && !settings.Empty) @@ -65,12 +61,8 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder var settings = builder.Configuration.GetTeams(); var loggingSettings = builder.Configuration.GetTeamsLogging(); - // cloud environment - CloudEnvironment? cloud = null; - if (settings.Cloud is not null) - { - cloud = CloudEnvironment.FromName(settings.Cloud); - } + // cloud environment (base preset + per-endpoint overrides) + var cloud = settings.ResolveCloud(); // client credentials if (settings.ClientId is not null && settings.ClientSecret is not null && !settings.Empty) @@ -79,12 +71,8 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder settings.ClientId, settings.ClientSecret, settings.TenantId - ); - - if (cloud is not null) - { - credentials.Cloud = cloud; - } + ) + { Cloud = cloud }; appBuilder = appBuilder.AddCredentials(credentials); } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs index e2efbe0b..f0ab906f 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs @@ -120,7 +120,7 @@ public static class EntraTokenAuthConstants public static IHostApplicationBuilder AddTeamsTokenAuthentication(this IHostApplicationBuilder builder, bool skipAuth = false) { var settings = builder.Configuration.GetTeams(); - var cloud = settings.Cloud is not null ? CloudEnvironment.FromName(settings.Cloud) : CloudEnvironment.Public; + var cloud = settings.ResolveCloud(); var teamsValidationSettings = new TeamsValidationSettings(cloud); if (!string.IsNullOrEmpty(settings.ClientId)) diff --git a/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs index afc2dc21..4ee37dce 100644 --- a/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs +++ b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs @@ -100,4 +100,80 @@ public void FromName_ReturnsStaticInstances() Assert.Same(CloudEnvironment.USGovDoD, CloudEnvironment.FromName("USGovDoD")); Assert.Same(CloudEnvironment.China, CloudEnvironment.FromName("China")); } + + [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(loginTenant: "my-tenant-id"); + + Assert.NotSame(env, result); + Assert.Equal("my-tenant-id", result.LoginTenant); + 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.TokenIssuer, result.TokenIssuer); + Assert.Equal(env.ChannelService, result.ChannelService); + Assert.Equal(env.OAuthRedirectUrl, result.OAuthRedirectUrl); + } + + [Fact] + public void WithOverrides_MultipleOverrides_ReplacesCorrectProperties() + { + var env = CloudEnvironment.China; + + var result = env.WithOverrides( + loginEndpoint: "https://custom.login.cn", + loginTenant: "custom-tenant", + tokenServiceUrl: "https://custom.token.cn" + ); + + Assert.Equal("https://custom.login.cn", result.LoginEndpoint); + Assert.Equal("custom-tenant", result.LoginTenant); + Assert.Equal("https://custom.token.cn", result.TokenServiceUrl); + // unchanged + Assert.Equal(env.BotScope, result.BotScope); + Assert.Equal(env.OpenIdMetadataUrl, result.OpenIdMetadataUrl); + Assert.Equal(env.TokenIssuer, result.TokenIssuer); + Assert.Equal(env.ChannelService, result.ChannelService); + Assert.Equal(env.OAuthRedirectUrl, result.OAuthRedirectUrl); + } + + [Fact] + public void WithOverrides_AllOverrides_ReplacesAllProperties() + { + var env = CloudEnvironment.Public; + + var result = env.WithOverrides( + loginEndpoint: "a", + loginTenant: "b", + botScope: "c", + tokenServiceUrl: "d", + openIdMetadataUrl: "e", + tokenIssuer: "f", + channelService: "g", + oauthRedirectUrl: "h" + ); + + Assert.Equal("a", result.LoginEndpoint); + Assert.Equal("b", result.LoginTenant); + Assert.Equal("c", result.BotScope); + Assert.Equal("d", result.TokenServiceUrl); + Assert.Equal("e", result.OpenIdMetadataUrl); + Assert.Equal("f", result.TokenIssuer); + Assert.Equal("g", result.ChannelService); + Assert.Equal("h", result.OAuthRedirectUrl); + } } From c8d87f2796cef098a7c611e4ee5e120ce835bbb1 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:10:51 -0700 Subject: [PATCH 3/5] Clean up PR: rename BotScope instance to ActiveBotScope, remove unrelated files - Keep static BotTokenClient.BotScope unchanged (avoids breaking change) - Add ActiveBotScope instance property for per-cloud scope configuration - Remove CLAUDE.md, Claude-KB.md, and .gitignore session file entries that were unrelated to sovereign cloud support Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 5 +++++ Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs | 6 +++--- Libraries/Microsoft.Teams.Apps/App.cs | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 26d20f91..def52263 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ # local app settings files appsettings.Local.json +appsettings.Development.json +launchsettings.json # User-specific files *.rsuser @@ -486,3 +488,6 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# Claude local settings +.claude/settings.local.json diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs index 505144ba..b79a89ca 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs @@ -7,9 +7,9 @@ namespace Microsoft.Teams.Api.Clients; public class BotTokenClient : Client { - public static readonly string DefaultBotScope = "https://api.botframework.com/.default"; + public static readonly string BotScope = "https://api.botframework.com/.default"; public static readonly string GraphScope = "https://graph.microsoft.com/.default"; - public string BotScope { get; set; } = DefaultBotScope; + public string ActiveBotScope { get; set; } = BotScope; public BotTokenClient() : this(default) { @@ -38,7 +38,7 @@ public BotTokenClient(IHttpClientFactory factory, CancellationToken cancellation public virtual async Task GetAsync(IHttpCredentials credentials, IHttpClient? http = null) { - return await credentials.Resolve(http ?? _http, [BotScope], _cancellationToken); + return await credentials.Resolve(http ?? _http, [ActiveBotScope], _cancellationToken); } public async Task GetGraphAsync(IHttpCredentials credentials, IHttpClient? http = null) diff --git a/Libraries/Microsoft.Teams.Apps/App.cs b/Libraries/Microsoft.Teams.Apps/App.cs index 1a783af1..382e4ff3 100644 --- a/Libraries/Microsoft.Teams.Apps/App.cs +++ b/Libraries/Microsoft.Teams.Apps/App.cs @@ -79,7 +79,7 @@ public App(AppOptions? options = null) if (Token.IsExpired) { - var res = Credentials.Resolve(TokenClient, [.. Token.Scopes.DefaultIfEmpty(cloud.BotScope)]) + var res = Credentials.Resolve(TokenClient, [.. Token.Scopes.DefaultIfEmpty(Api!.Bots.Token.ActiveBotScope)]) .ConfigureAwait(false) .GetAwaiter() .GetResult(); @@ -92,7 +92,7 @@ public App(AppOptions? options = null) }; Api = new ApiClient("https://smba.trafficmanager.net/teams/", Client); - Api.Bots.Token.BotScope = cloud.BotScope; + Api.Bots.Token.ActiveBotScope = cloud.BotScope; Api.Bots.SignIn.TokenServiceUrl = cloud.TokenServiceUrl; Api.Users.Token.TokenServiceUrl = cloud.TokenServiceUrl; Container = new Container(); From bc733c471d6d68bab0b8af1e97e31fa38f3f4009 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:41:17 -0700 Subject: [PATCH 4/5] Add sovereign cloud validation and ActiveBotScope tests - CloudEnvironmentTests: ClientCredentials cloud property defaults and assignment - BotTokenClientTests: ActiveBotScope defaults, overrides, and usage in GetAsync - TeamsValidationSettingsTests: sovereign cloud issuers, JWKS, login endpoints, tenant-specific URLs, and audience handling Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Auth/CloudEnvironmentTests.cs | 30 +++++ .../Clients/BotTokenClientTests.cs | 45 ++++++++ .../TeamsValidationSettingsTests.cs | 107 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs diff --git a/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs index 4ee37dce..73fd242f 100644 --- a/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs +++ b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs @@ -176,4 +176,34 @@ public void WithOverrides_AllOverrides_ReplacesAllProperties() Assert.Equal("g", result.ChannelService); Assert.Equal("h", result.OAuthRedirectUrl); } + + [Fact] + public void ClientCredentials_DefaultsToPublicCloud() + { + var creds = new ClientCredentials("id", "secret"); + Assert.Same(CloudEnvironment.Public, creds.Cloud); + } + + [Fact] + public void ClientCredentials_CloudCanBeSet() + { + var creds = new ClientCredentials("id", "secret") + { + Cloud = CloudEnvironment.USGov + }; + Assert.Same(CloudEnvironment.USGov, creds.Cloud); + } + + [Fact] + public void ClientCredentials_UsesCloudLoginTenantWhenTenantIdNull() + { + var creds = new ClientCredentials("id", "secret") + { + Cloud = CloudEnvironment.USGov + }; + + // TenantId is null, so Cloud.LoginTenant should be used + Assert.Null(creds.TenantId); + Assert.Equal("MicrosoftServices.onmicrosoft.us", creds.Cloud.LoginTenant); + } } diff --git a/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs b/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs index 8b01c9fb..74a0e6d3 100644 --- a/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs +++ b/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs @@ -147,4 +147,49 @@ public async Task BotTokenClient_HttpClientOptions_Async() Assert.Equal(expectedTenantId, actualTenantId); Assert.Equal("https://api.botframework.com/.default", actualScope[0]); } + + [Fact] + public void ActiveBotScope_DefaultsToPublicBotScope() + { + var client = new BotTokenClient(); + Assert.Equal(BotTokenClient.BotScope, client.ActiveBotScope); + Assert.Equal("https://api.botframework.com/.default", client.ActiveBotScope); + } + + [Fact] + public void ActiveBotScope_CanBeOverridden() + { + var client = new BotTokenClient(); + client.ActiveBotScope = "https://api.botframework.us/.default"; + Assert.Equal("https://api.botframework.us/.default", client.ActiveBotScope); + } + + [Fact] + public void BotScope_StaticFieldUnchanged() + { + Assert.Equal("https://api.botframework.com/.default", BotTokenClient.BotScope); + } + + [Fact] + public async Task BotTokenClient_ActiveBotScope_UsedInGetAsync() + { + var cancellationToken = new CancellationToken(); + string[] actualScope = [""]; + TokenFactory tokenFactory = new TokenFactory(async (tenantId, scope) => + { + actualScope = scope; + return await Task.FromResult(new TokenResponse + { + TokenType = "Bearer", + AccessToken = accessToken + }); + }); + var credentials = new TokenCredentials("clientId", tokenFactory); + var botTokenClient = new BotTokenClient(cancellationToken); + botTokenClient.ActiveBotScope = "https://api.botframework.us/.default"; + + await botTokenClient.GetAsync(credentials); + + Assert.Equal("https://api.botframework.us/.default", actualScope[0]); + } } \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs new file mode 100644 index 00000000..4fdf3745 --- /dev/null +++ b/Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Teams.Api.Auth; +using Microsoft.Teams.Plugins.AspNetCore.Extensions; + +namespace Microsoft.Teams.Plugins.AspNetCore.Tests.Extensions; + +public class TeamsValidationSettingsTests +{ + [Fact] + public void DefaultConstructor_UsesPublicCloud() + { + var settings = new TeamsValidationSettings(); + + Assert.Equal("https://login.botframework.com/v1/.well-known/openidconfiguration", settings.OpenIdMetadataUrl); + Assert.Equal("https://login.microsoftonline.com", settings.LoginEndpoint); + Assert.Contains("https://api.botframework.com", settings.Issuers); + } + + [Fact] + public void USGovCloud_HasCorrectSettings() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + Assert.Equal("https://login.botframework.azure.us/v1/.well-known/openidconfiguration", settings.OpenIdMetadataUrl); + Assert.Equal("https://login.microsoftonline.us", settings.LoginEndpoint); + Assert.Contains("https://api.botframework.us", settings.Issuers); + } + + [Fact] + public void ChinaCloud_HasCorrectSettings() + { + var settings = new TeamsValidationSettings(CloudEnvironment.China); + + Assert.Equal("https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", settings.OpenIdMetadataUrl); + Assert.Equal("https://login.partner.microsoftonline.cn", settings.LoginEndpoint); + Assert.Contains("https://api.botframework.azure.cn", settings.Issuers); + } + + [Fact] + public void AllClouds_IncludeEmulatorIssuers() + { + var clouds = new[] { CloudEnvironment.Public, CloudEnvironment.USGov, CloudEnvironment.USGovDoD, CloudEnvironment.China }; + + foreach (var cloud in clouds) + { + var settings = new TeamsValidationSettings(cloud); + + // Emulator issuers should always be present + Assert.Contains(settings.Issuers, i => i.Contains("d6d49420-f39b-4df7-a1dc-d59a935871db")); + Assert.Contains(settings.Issuers, i => i.Contains("f8cdef31-a31e-4b4a-93e4-5f571e91255a")); + } + } + + [Fact] + public void GetTenantSpecificOpenIdMetadataUrl_UsesCloudLoginEndpoint() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + var url = settings.GetTenantSpecificOpenIdMetadataUrl("my-tenant"); + + Assert.Equal("https://login.microsoftonline.us/my-tenant/v2.0/.well-known/openid-configuration", url); + } + + [Fact] + public void GetTenantSpecificOpenIdMetadataUrl_DefaultsToCommon() + { + var settings = new TeamsValidationSettings(CloudEnvironment.China); + + var url = settings.GetTenantSpecificOpenIdMetadataUrl(null); + + Assert.Equal("https://login.partner.microsoftonline.cn/common/v2.0/.well-known/openid-configuration", url); + } + + [Fact] + public void GetValidIssuersForTenant_UsesCloudLoginEndpoint() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + var issuers = settings.GetValidIssuersForTenant("my-tenant").ToList(); + + Assert.Single(issuers); + Assert.Equal("https://login.microsoftonline.us/my-tenant/", issuers[0]); + } + + [Fact] + public void GetValidIssuersForTenant_ReturnsEmptyForNullTenant() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + var issuers = settings.GetValidIssuersForTenant(null).ToList(); + + Assert.Empty(issuers); + } + + [Fact] + public void AddDefaultAudiences_AddsClientIdAndApiPrefix() + { + var settings = new TeamsValidationSettings(CloudEnvironment.USGov); + + settings.AddDefaultAudiences("my-client-id"); + + Assert.Contains("my-client-id", settings.Audiences); + Assert.Contains("api://my-client-id", settings.Audiences); + } +} From b026c004e4598cab8f2b0f3a7cf8bf0bec2e1303 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:44:07 -0700 Subject: [PATCH 5/5] Add sovereign cloud support to core/ package Thread CloudEnvironment through the core/ MSAL-based architecture: - BotConfig: resolve Cloud from configuration (Cloud/CLOUD key) across all 3 config formats (BF, Core env vars, AzureAd section) - BotClientOptions: Cloud property for scope and instance resolution - AddBotApplicationExtensions: use cloud.LoginEndpoint for MSAL Instance (resolves 3 TODO comments), cloud-aware default scope - UserTokenClient: cloud-aware default token service endpoint Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Hosting/AddBotApplicationExtensions.cs | 33 +++++++++++-------- .../Hosting/BotClientOptions.cs | 7 ++++ .../Hosting/BotConfig.cs | 14 ++++++++ .../UserTokenClient.cs | 8 ++++- 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs index e264ba45..6feb1161 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/AddBotApplicationExtensions.cs @@ -13,6 +13,7 @@ using Microsoft.Identity.Abstractions; using Microsoft.Identity.Web; using Microsoft.Identity.Web.TokenCacheProviders.InMemory; +using Microsoft.Teams.Api.Auth; namespace Microsoft.Teams.Bot.Core.Hosting; @@ -109,11 +110,18 @@ private static IServiceCollection AddBotClient( string httpClientName, string sectionName) where TClient : class { - // Register options to defer scope configuration reading + // Register options to defer scope and cloud configuration reading services.AddOptions() .Configure((options, configuration) => { - options.Scope = "https://api.botframework.com/.default"; + // Resolve cloud environment from configuration + var cloudName = configuration[$"{sectionName}:Cloud"] + ?? configuration["Cloud"] + ?? configuration["CLOUD"]; + if (cloudName is not null) + options.Cloud = CloudEnvironment.FromName(cloudName); + + options.Scope = options.Cloud.BotScope; if (!string.IsNullOrEmpty(configuration[$"{sectionName}:Scope"])) options.Scope = configuration[$"{sectionName}:Scope"]!; if (!string.IsNullOrEmpty(configuration["Scope"])) @@ -202,7 +210,7 @@ private static IServiceCollection ConfigureMSALFromConfig(this IServiceCollectio return services; } - private static IServiceCollection ConfigureMSALWithSecret(this IServiceCollection services, string tenantId, string clientId, string clientSecret) + private static IServiceCollection ConfigureMSALWithSecret(this IServiceCollection services, string tenantId, string clientId, string clientSecret, CloudEnvironment cloud) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(clientId); @@ -210,8 +218,7 @@ private static IServiceCollection ConfigureMSALWithSecret(this IServiceCollectio services.Configure(MsalConfigKey, options => { - // TODO: Make Instance configurable - options.Instance = "https://login.microsoftonline.com/"; + options.Instance = cloud.LoginEndpoint + "/"; options.TenantId = tenantId; options.ClientId = clientId; options.ClientCredentials = [ @@ -225,7 +232,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); @@ -241,8 +248,7 @@ private static IServiceCollection ConfigureMSALWithFIC(this IServiceCollection s services.Configure(MsalConfigKey, options => { - // TODO: Make Instance configurable - options.Instance = "https://login.microsoftonline.com/"; + options.Instance = cloud.LoginEndpoint + "/"; options.TenantId = tenantId; options.ClientId = clientId; options.ClientCredentials = [ @@ -252,7 +258,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); @@ -268,8 +274,7 @@ private static IServiceCollection ConfigureMSALWithUMI(this IServiceCollection s services.Configure(MsalConfigKey, options => { - // TODO: Make Instance configurable - options.Instance = "https://login.microsoftonline.com/"; + options.Instance = cloud.LoginEndpoint + "/"; options.TenantId = tenantId; options.ClientId = clientId; }); @@ -282,18 +287,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/Hosting/BotClientOptions.cs b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs index 6316cc75..1d56331a 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotClientOptions.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Teams.Api.Auth; + namespace Microsoft.Teams.Bot.Core.Hosting; /// @@ -17,4 +19,9 @@ internal sealed class BotClientOptions /// Gets or sets the configuration section name. /// public string SectionName { get; set; } = "AzureAd"; + + /// + /// Gets or sets the cloud environment for sovereign cloud support. + /// + 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 16885dc4..c8d713c5 100644 --- a/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs +++ b/core/src/Microsoft.Teams.Bot.Core/Hosting/BotConfig.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Microsoft.Extensions.Configuration; +using Microsoft.Teams.Api.Auth; namespace Microsoft.Teams.Bot.Core.Hosting; @@ -44,6 +45,13 @@ internal sealed class BotConfig /// public string? FicClientId { get; set; } + /// + /// Gets or sets the cloud environment for sovereign cloud support. + /// Resolved from a cloud name string (e.g. "USGov", "China") via . + /// Defaults to if not specified. + /// + public CloudEnvironment Cloud { get; set; } = CloudEnvironment.Public; + /// /// Creates a BotConfig from Bot Framework SDK configuration format. /// @@ -58,6 +66,7 @@ public static BotConfig FromBFConfig(IConfiguration configuration) TenantId = configuration["MicrosoftAppTenantId"] ?? string.Empty, ClientId = configuration["MicrosoftAppId"] ?? string.Empty, ClientSecret = configuration["MicrosoftAppPassword"], + Cloud = ResolveCloud(configuration["Cloud"]), }; } @@ -80,6 +89,7 @@ public static BotConfig FromCoreConfig(IConfiguration configuration) ClientId = configuration["CLIENT_ID"] ?? string.Empty, ClientSecret = configuration["CLIENT_SECRET"], FicClientId = configuration["MANAGED_IDENTITY_CLIENT_ID"], + Cloud = ResolveCloud(configuration["CLOUD"] ?? configuration["Cloud"]), }; } @@ -103,6 +113,10 @@ public static BotConfig FromAadConfig(IConfiguration configuration, string secti TenantId = section["TenantId"] ?? string.Empty, ClientId = section["ClientId"] ?? string.Empty, ClientSecret = section["ClientSecret"], + Cloud = ResolveCloud(section["Cloud"] ?? configuration["Cloud"]), }; } + + private static CloudEnvironment ResolveCloud(string? cloudName) => + cloudName is not null ? CloudEnvironment.FromName(cloudName) : CloudEnvironment.Public; } diff --git a/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs b/core/src/Microsoft.Teams.Bot.Core/UserTokenClient.cs index fe2a3e4b..e8cd64a8 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.Api.Auth; using Microsoft.Teams.Bot.Core.Http; using Microsoft.Teams.Bot.Core.Schema; @@ -27,7 +28,12 @@ public class UserTokenClient(HttpClient httpClient, IConfiguration configuration internal const string UserTokenHttpClientName = "BotUserTokenClient"; private readonly ILogger _logger = logger; private readonly BotHttpClient _botHttpClient = new(httpClient, logger); - private readonly string _apiEndpoint = configuration["UserTokenApiEndpoint"] ?? "https://token.botframework.com"; + private readonly string _apiEndpoint = configuration["UserTokenApiEndpoint"] + ?? (configuration["Cloud"] ?? configuration["CLOUD"]) switch + { + string cloudName => CloudEnvironment.FromName(cloudName).TokenServiceUrl, + null => CloudEnvironment.Public.TokenServiceUrl + }; private readonly JsonSerializerOptions _defaultOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; internal AgenticIdentity? AgenticIdentity { get; set; }