diff --git a/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs b/Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs index 7ae6d974c..e198eb73d 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 000000000..ef5681590 --- /dev/null +++ b/Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs @@ -0,0 +1,171 @@ +// 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 Microsoft Graph token scope (e.g. "https://graph.microsoft.com/.default"). + /// + public string GraphScope { get; } + + public CloudEnvironment( + string loginEndpoint, + string loginTenant, + string botScope, + string tokenServiceUrl, + string openIdMetadataUrl, + string tokenIssuer, + string graphScope) + { + LoginEndpoint = loginEndpoint.TrimEnd('/'); + LoginTenant = loginTenant; + BotScope = botScope; + TokenServiceUrl = tokenServiceUrl.TrimEnd('/'); + OpenIdMetadataUrl = openIdMetadataUrl; + TokenIssuer = tokenIssuer; + GraphScope = graphScope; + } + + /// + /// Microsoft public (commercial) cloud. + /// + public static readonly CloudEnvironment Public = new( + loginEndpoint: "https://login.microsoftonline.com", + loginTenant: "botframework.com", + botScope: "https://api.botframework.com/.default", + tokenServiceUrl: "https://token.botframework.com", + openIdMetadataUrl: "https://login.botframework.com/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.com", + graphScope: "https://graph.microsoft.com/.default" + ); + + /// + /// US Government Community Cloud High (GCCH). + /// + public static readonly CloudEnvironment USGov = new( + loginEndpoint: "https://login.microsoftonline.us", + loginTenant: "MicrosoftServices.onmicrosoft.us", + botScope: "https://api.botframework.us/.default", + tokenServiceUrl: "https://tokengcch.botframework.azure.us", + openIdMetadataUrl: "https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.us", + graphScope: "https://graph.microsoft.us/.default" + ); + + /// + /// US Government Department of Defense (DoD). + /// + public static readonly CloudEnvironment USGovDoD = new( + loginEndpoint: "https://login.microsoftonline.us", + loginTenant: "MicrosoftServices.onmicrosoft.us", + botScope: "https://api.botframework.us/.default", + tokenServiceUrl: "https://apiDoD.botframework.azure.us", + openIdMetadataUrl: "https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.us", + graphScope: "https://dod-graph.microsoft.us/.default" + ); + + /// + /// China cloud (21Vianet). + /// + public static readonly CloudEnvironment China = new( + loginEndpoint: "https://login.partner.microsoftonline.cn", + loginTenant: "microsoftservices.partner.onmschina.cn", + botScope: "https://api.botframework.azure.cn/.default", + tokenServiceUrl: "https://token.botframework.azure.cn", + openIdMetadataUrl: "https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", + tokenIssuer: "https://api.botframework.azure.cn", + graphScope: "https://microsoftgraph.chinacloudapi.cn/.default" + ); + + /// + /// Creates a new by applying non-null 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? graphScope = null) + { + if (loginEndpoint is null && loginTenant is null && botScope is null && + tokenServiceUrl is null && openIdMetadataUrl is null && tokenIssuer is null && + graphScope is null) + { + return this; + } + + return new CloudEnvironment( + loginEndpoint ?? LoginEndpoint, + loginTenant ?? LoginTenant, + botScope ?? BotScope, + tokenServiceUrl ?? TokenServiceUrl, + openIdMetadataUrl ?? OpenIdMetadataUrl, + tokenIssuer ?? TokenIssuer, + graphScope ?? GraphScope + ); + } + + /// + /// Resolves a cloud environment name (case-insensitive) to its corresponding instance. + /// Valid names: "Public", "USGov", "USGovDoD", "China". + /// + public static CloudEnvironment FromName(string name) + { + ArgumentNullException.ThrowIfNull(name); + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Cloud environment name cannot be empty or whitespace.", nameof(name)); + } + + return name.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/ApiClient.cs b/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs index b1588d2ae..c61222ced 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/ApiClient.cs @@ -83,8 +83,12 @@ public ApiClient(ApiClient client, CancellationToken cancellationToken) : base(c { ServiceUrl = client.ServiceUrl; Bots = new BotClient(_http, cancellationToken); + Bots.Token.ActiveBotScope = client.Bots.Token.ActiveBotScope; + Bots.Token.ActiveGraphScope = client.Bots.Token.ActiveGraphScope; + Bots.SignIn.TokenServiceUrl = client.Bots.SignIn.TokenServiceUrl; Conversations = new ConversationClient(ServiceUrl, _http, cancellationToken); Users = new UserClient(_http, cancellationToken); + Users.Token.TokenServiceUrl = client.Users.Token.TokenServiceUrl; Teams = new TeamClient(ServiceUrl, _http, cancellationToken); Meetings = new MeetingClient(ServiceUrl, _http, cancellationToken); } diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs index d1d8d792c..716b02945 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() { @@ -32,7 +34,7 @@ public async Task GetUrlAsync(GetUrlRequest request, CancellationToken c var token = cancellationToken != default ? cancellationToken : _cancellationToken; 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, token); @@ -44,7 +46,7 @@ public async Task GetUrlAsync(GetUrlRequest request, CancellationToken c var token = cancellationToken != default ? cancellationToken : _cancellationToken; 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, token); diff --git a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs index a7deef3f1..e4870df58 100644 --- a/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs +++ b/Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs @@ -9,6 +9,8 @@ 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 string ActiveGraphScope { get; set; } = GraphScope; public BotTokenClient() : this(default) { @@ -38,12 +40,12 @@ public BotTokenClient(IHttpClientFactory factory, CancellationToken cancellation public virtual async Task GetAsync(IHttpCredentials credentials, IHttpClient? http = null, CancellationToken cancellationToken = default) { var token = cancellationToken != default ? cancellationToken : _cancellationToken; - return await credentials.Resolve(http ?? _http, [BotScope], token); + return await credentials.Resolve(http ?? _http, [ActiveBotScope], token); } public async Task GetGraphAsync(IHttpCredentials credentials, IHttpClient? http = null, CancellationToken cancellationToken = default) { var token = cancellationToken != default ? cancellationToken : _cancellationToken; - return await credentials.Resolve(http ?? _http, [GraphScope], token); + return await credentials.Resolve(http ?? _http, [ActiveGraphScope], token); } } \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs b/Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs index 518087617..2a476dfcd 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 @@ -39,7 +41,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio { var token = cancellationToken != default ? cancellationToken : _cancellationToken; 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, token); return res.Body; } @@ -48,7 +50,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio { var token = cancellationToken != default ? cancellationToken : _cancellationToken; 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, token); return res.Body; } @@ -57,7 +59,7 @@ public UserTokenClient(IHttpClientFactory factory, CancellationToken cancellatio { var token = cancellationToken != default ? cancellationToken : _cancellationToken; 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, token); return res.Body; } @@ -66,7 +68,7 @@ public async Task SignOutAsync(SignOutRequest request, CancellationToken cancell { var token = cancellationToken != default ? cancellationToken : _cancellationToken; 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, token); } @@ -84,7 +86,7 @@ public async Task SignOutAsync(SignOutRequest request, CancellationToken cancell // 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, token); diff --git a/Libraries/Microsoft.Teams.Apps/App.cs b/Libraries/Microsoft.Teams.Apps/App.cs index b56a8c9d0..ef4800c0b 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,10 @@ public App(AppOptions? options = null) }; Api = new ApiClient("https://smba.trafficmanager.net/teams/", Client); + Api.Bots.Token.ActiveBotScope = cloud.BotScope; + Api.Bots.Token.ActiveGraphScope = cloud.GraphScope; + 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/AppBuilder.cs b/Libraries/Microsoft.Teams.Apps/AppBuilder.cs index 63e5d6f5f..8af2e0e73 100644 --- a/Libraries/Microsoft.Teams.Apps/AppBuilder.cs +++ b/Libraries/Microsoft.Teams.Apps/AppBuilder.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; @@ -98,6 +99,12 @@ public AppBuilder AddOAuth(string defaultConnectionName) return this; } + public AppBuilder AddCloud(CloudEnvironment cloud) + { + _options.Cloud = cloud; + return this; + } + public App Build() { return new App(_options); diff --git a/Libraries/Microsoft.Teams.Apps/AppOptions.cs b/Libraries/Microsoft.Teams.Apps/AppOptions.cs index b923afa21..766016bc2 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 11a4e5321..d82780b4a 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 @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. using Microsoft.Teams.Api.Auth; @@ -10,21 +10,76 @@ 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 Microsoft Graph token scope. + public string? GraphScope { 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 + ?? (!string.IsNullOrWhiteSpace(Cloud) ? CloudEnvironment.FromName(Cloud) : null) + ?? CloudEnvironment.Public; + + return baseCloud.WithOverrides( + loginEndpoint: LoginEndpoint, + loginTenant: LoginTenant, + botScope: BotScope, + tokenServiceUrl: TokenServiceUrl, + openIdMetadataUrl: OpenIdMetadataUrl, + tokenIssuer: TokenIssuer, + graphScope: GraphScope + ); + } + 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; + } + else if (options.Credentials is ClientCredentials existingCredentials) + { + existingCredentials.Cloud = cloud; } return options; } -} \ No newline at end of file +} 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 01f28920a..507dfb438 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,12 @@ public static IHostApplicationBuilder AddTeamsCore(this IHostApplicationBuilder settings.ClientId, settings.ClientSecret, settings.TenantId - ); + ) + { Cloud = cloud }; + } + else if (options.Credentials is ClientCredentials existingCredentials) + { + existingCredentials.Cloud = cloud; } options.Logger ??= new ConsoleLogger(loggingSettings); @@ -56,14 +65,21 @@ 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(); + appBuilder = appBuilder.AddCloud(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 - )); + ) + { 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 760d94da6..1d814979f 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 @@ -119,8 +119,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 5443c9281..1d338ca02 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,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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 +29,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 +46,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 000000000..fe9572a96 --- /dev/null +++ b/Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs @@ -0,0 +1,195 @@ +// 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); + } + + [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); + } + + [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); + } + + [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); + } + + [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); + } + + [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); + } + + [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", + graphScope: "g" + ); + + 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.GraphScope); + } + + [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); + } +} \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs b/Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs index 8b01c9fbe..74a0e6d3e 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 000000000..49658400d --- /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); + } +} \ No newline at end of file