From 8ecde0369355e973295e391e92ff3fed037e843c Mon Sep 17 00:00:00 2001 From: Rajan Date: Thu, 26 Feb 2026 10:59:27 -0500 Subject: [PATCH 1/3] Add Claude Code startup greeting, knowledge base, and session support - Add Startup section to CLAUDE.md with knowledge file loading, session context, private notes support, and quick-start commands - Add Lessons Learned section to CLAUDE.md for persistent knowledge - Create Claude-KB.md for cross-session learning - Add session-context.md and *-private.md to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 ++ CLAUDE.md | 178 +++++++++++++++++++++++++++++++++++++++++++++++++++ Claude-KB.md | 3 + 3 files changed, 185 insertions(+) create mode 100644 CLAUDE.md create mode 100644 Claude-KB.md diff --git a/.gitignore b/.gitignore index def52263..7c590995 100644 --- a/.gitignore +++ b/.gitignore @@ -491,3 +491,7 @@ $RECYCLE.BIN/ # Claude local settings .claude/settings.local.json + +# Claude session and private files +session-context.md +*-private.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..06a84e8f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,178 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Startup + +Before responding to the user's first message, complete these steps: + +### 1. Read knowledge files +- Read `Claude-KB.md` in this directory (domain knowledge, lessons learned). Create it if it doesn't exist with a `## Lessons Learned` heading. +- **Don't read md files from the parent directory unless the user requests it** — this slows down session start. +- Look for a `*-private.md` file matching the user's name (e.g., `Rajan-private.md`). If one exists, read it — it contains personal TODOs, preferences, and reminders. These files are gitignored and never committed. +- The private file may reference a durable location (e.g., a private git repo). If it does, also read and update that location for persistent notes and TODOs. + +### 2. Read session context +- Read `session-context.md` if it exists. It contains ephemeral state from the previous session: what was in flight, what to pick up, any "don't forget" items. This file is gitignored and overwritten each save. +- Surface relevant items in the greeting (e.g., "Last session you were working on PR 1234"). + +### 3. Greet the user and surface +- Any open TODOs or reminders from private notes +- Common scenarios / quick-start commands: + - **Build the solution** — `dotnet build` + - **Run all tests** — `dotnet test` + - **Run a specific test** — `dotnet test --filter "FullyQualifiedName~TestName"` + - **Run a sample app** — `dotnet run --project Samples/Samples.Echo` + - **Format code** — `dotnet format` + - **Create NuGet packages** — `dotnet pack` + +## Build Commands + +```bash +dotnet build # Build solution +dotnet test # Run all tests +dotnet test -v d # Run tests with detailed verbosity +dotnet format # Format code (EditorConfig enforced) +dotnet pack # Create NuGet packages +``` + +Run a specific test project: +```bash +dotnet test Tests/Microsoft.Teams.Apps.Tests +``` + +Run a single test by name: +```bash +dotnet test --filter "FullyQualifiedName~TestMethodName" +``` + +Run tests with coverage: +```bash +dotnet test --collect:"XPlat Code Coverage" +``` + +Run a specific sample: +```bash +dotnet run --project Samples/Samples.Echo +dotnet run --project Samples/Samples.Lights +``` + +## Development Workflow + +- Cannot push directly to main - all changes require a pull request +- Create a feature branch, make changes, then open a PR +- CI runs build, test, and lint checks on PRs + +## Architecture Overview + +This is the Microsoft Teams SDK for .NET (`Microsoft.Teams.sln`) - a suite of packages for building Teams bots and apps. + +### Core Libraries (Libraries/) + +- **Microsoft.Teams.Apps** - Core bot functionality: activity handling, message processing, routing, context management +- **Microsoft.Teams.AI** - AI/LLM integration: chat plugins, function definitions, prompt templates +- **Microsoft.Teams.AI.Models.OpenAI** - OpenAI-specific model implementation +- **Microsoft.Teams.Api** - Teams API client for bot-to-Teams communication +- **Microsoft.Teams.Cards** - Adaptive Cards support +- **Microsoft.Teams.Common** - Shared utilities, JSON helpers, HTTP, logging, storage patterns + +### Extensions (Libraries/Microsoft.Teams.Extensions/) + +- **Configuration** - Configuration helpers +- **Hosting** - ASP.NET Core DI integration +- **Logging** - Microsoft.Extensions.Logging integration +- **Graph** - Microsoft Graph integration + +### Plugins (Libraries/Microsoft.Teams.Plugins/) + +- **AspNetCore** - Core middleware for ASP.NET Core +- **AspNetCore.DevTools** - Development tools +- **AspNetCore.BotBuilder** - Bot Builder SDK adapter +- **External.Mcp** / **External.McpClient** - Model Context Protocol integration + +## Code Patterns + +### Basic App Setup + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.AddTeams(); // Register Teams services +var app = builder.Build(); +var teams = app.UseTeams(); // Get Teams middleware + +teams.OnMessage(async context => { // Handle messages + await context.Send("Hello!"); +}); + +app.Run(); +``` + +### AI Plugin + +```csharp +[Prompt] +[Prompt.Description("description")] +[Prompt.Instructions("system instructions")] +public class MyPrompt(IContext.Accessor accessor) +{ + [Function] + [Function.Description("what this function does")] + public string MyFunction([Param("param description")] string input) + { + return "result"; + } +} +``` + +## Code Style + +EditorConfig is strictly enforced. Key conventions: + +- **Namespaces**: File-scoped (`namespace Foo;`) +- **Fields**: `_camelCase` for private, `s_camelCase` for private static +- **Nullable**: Enabled throughout +- **Async**: All async methods, CancellationToken support + +All files require Microsoft copyright header: +```csharp +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +``` + +## Testing + +- xUnit with Moq for mocking, implicit `using Xunit` in test projects +- Test projects target net9.0 (libraries target net8.0) +- Test naming: `{LibraryName}.Tests` in `Tests/` directory +- Use `Microsoft.Teams.Apps.Testing` for test utilities + +## Lessons Learned + +This workspace is a **learning system**. Claude-KB.md contains a `## Lessons Learned` section that persists knowledge across sessions. + +### When to add an entry + +Proactively add a lesson whenever you encounter: + +- **Unexpected behavior** — an API, tool, or workflow didn't work as expected and you found the cause +- **Workarounds** — a problem required a non-obvious solution that future sessions should know about +- **User preferences** — the user corrects your approach or states a preference +- **Process discoveries** — you learn how something actually works vs. how it's documented +- **Pitfalls** — something that wasted time and could be avoided next time + +### How to add an entry + +Append to the `## Lessons Learned` section in `Claude-KB.md` using this format: + +```markdown +### YYYY-MM-DD: Short descriptive title +Description of what happened and what to do differently. Keep it concise and actionable. +``` + +### Guidelines + +- Write for your future self — assume no prior context from this session +- Be specific: include tool names, flag names, error messages, or exact steps +- Don't duplicate existing entries — read the section first +- One entry per distinct lesson; don't bundle unrelated things +- Ask the user before adding if you're unsure whether something qualifies diff --git a/Claude-KB.md b/Claude-KB.md new file mode 100644 index 00000000..1db80c16 --- /dev/null +++ b/Claude-KB.md @@ -0,0 +1,3 @@ +# Claude Knowledge Base — Microsoft Teams SDK for .NET + +## Lessons Learned From e856961e34adab2afb7b7b8da505b1abcd78031c Mon Sep 17 00:00:00 2001 From: Rajan Date: Thu, 26 Feb 2026 11:30:23 -0500 Subject: [PATCH 2/3] 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 5f7768bc..ed2dd1f6 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 e0b56f6d1f099b370dc70e87c2ee716090b4a131 Mon Sep 17 00:00:00 2001 From: Rajan Date: Thu, 26 Feb 2026 14:07:08 -0500 Subject: [PATCH 3/3] 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); + } }