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/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..7910727a --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs @@ -0,0 +1,175 @@ +// 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" + ); + + /// + /// 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". + /// + 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..b79a89ca 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs @@ -9,6 +9,7 @@ public class BotTokenClient : Client { public static readonly string BotScope = "https://api.botframework.com/.default"; public static readonly string GraphScope = "https://graph.microsoft.com/.default"; + public string ActiveBotScope { get; set; } = BotScope; public BotTokenClient() : this(default) { @@ -37,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.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..382e4ff3 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(Api!.Bots.Token.ActiveBotScope)]) .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.ActiveBotScope = 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..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 @@ -10,19 +10,74 @@ public class TeamsSettings public string? ClientId { get; set; } public string? ClientSecret { get; set; } 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(); + var cloud = ResolveCloud(options.Cloud); + options.Cloud = cloud; + 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..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,6 +31,10 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder var settings = builder.Configuration.GetTeams(); var loggingSettings = builder.Configuration.GetTeamsLogging(); + // 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) { @@ -38,7 +42,8 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder settings.ClientId, settings.ClientSecret, settings.TenantId - ); + ) + { Cloud = cloud }; } options.Logger ??= new ConsoleLogger(loggingSettings); @@ -56,14 +61,20 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder var settings = builder.Configuration.GetTeams(); var loggingSettings = builder.Configuration.GetTeamsLogging(); + // 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) { - appBuilder = appBuilder.AddCredentials(new ClientCredentials( + var credentials = new ClientCredentials( settings.ClientId, settings.ClientSecret, settings.TenantId - )); + ) + { 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..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 @@ -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.ResolveCloud(); - 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..73fd242f --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs @@ -0,0 +1,209 @@ +// 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")); + } + + [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); + } + + [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); + } +} 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; }