Add sovereign cloud support (GCCH, DoD, China)#352
Conversation
Manual Test ResultsTested locally by linking a scaffolded C# echo bot to this branch via Environment
Tests
ObservationThe config path ( Tested with teams-sdk-dev tooling. |
Updated Test Results (after
|
| # | Test | Result |
|---|---|---|
| 1 | Invalid cloud name ("Cloud": "Narnia") |
Pass. Crashes with: Unknown cloud environment: 'Narnia'. Valid values are: Public, USGov, USGovDoD, China. |
| 2 | Default behavior (no Cloud key) | Pass. Bot starts, echoes messages. Defaults to CloudEnvironment.Public. |
| 3 | Named preset ("Cloud": "Public") |
Pass. Starts normally. |
| 4 | Single endpoint override ("TokenServiceUrl": "https://bogus.example.com/token") |
Pass. Bot starts (override accepted at startup, used at runtime). Proves individual override is wired through ResolveCloud → WithOverrides. |
| 5 | Preset + override ("Cloud": "USGov" + "LoginEndpoint": "https://custom-login.example.com") |
Pass. Bot starts with USGov as base and the custom LoginEndpoint applied on top. |
| 6 | Unit tests (CloudEnvironmentTests) |
Pass. All 21 tests pass, including 4 new WithOverrides tests (all-nulls, single, multiple, all overrides). |
Design Notes
WithOverridesreturnsthiswhen all overrides are null — nice zero-allocation fast path.ResolveCloudchains cleanly: programmaticAppOptions.Cloud→ config"Cloud"preset →Publicfallback → per-endpoint overrides. Priority order is clear.- All 8 endpoint properties are individually overridable from
appsettings.jsonunder theTeamssection.
URL Verification: CloudEnvironment presets vs Bot Framework docsCross-referenced all USGov (GCCH)
USGovDoD
Notes on the two
|
rido-min
left a comment
There was a problem hiding this comment.
Thank you for addressing sovereign cloud support - this is an important capability for government and regional customers.
I have some architectural concerns about the current approach that I'd like to discuss before we proceed:
- Hard-coded endpoint presets create a maintenance burden
The CloudEnvironment class embeds specific URLs for each sovereign cloud (GCCH, DoD, China). These endpoints have historically changed over time - when Microsoft updates them, this requires an SDK release
and forces all consumers to update their SDK version, even though their application logic hasn't changed. This couples deployment cycles unnecessarily.
The MSAL library itself avoids this pattern - it relies on well-known discovery documents (OpenID configuration endpoints) to resolve the actual service URLs at runtime. This approach is more resilient to
endpoint changes.
- Configuration-only approach is more flexible
Rather than providing presets, consider allowing sovereign cloud configuration entirely through settings:
{
"Teams": {
"LoginEndpoint": "https://login.microsoftonline.us",
"TokenServiceUrl": "https://tokengcch.botframework.azure.us",
...
}
}
This puts control in the hands of the deploying organization (who often have specific requirements in sovereign environments) rather than requiring SDK changes.
- Scattered client configuration
The current implementation adds TokenServiceUrl properties to individual clients (BotSignInClient, UserTokenClient, BotTokenClient). This means each client must be configured separately, which is error-prone
and harder to maintain than a centralized configuration that flows through to all clients.
Alternative approach to consider:
- Use the OpenID discovery document (already referenced as OpenIdMetadataUrl) to dynamically resolve endpoints where possible
- If presets are valuable for developer convenience, consider shipping them as a separate configuration file or NuGet package that can be updated independently of the core SDK
- Centralize cloud configuration in one place that all clients consume, rather than scattering it across multiple client classes
Would it be worth discussing these trade-offs before proceeding?
I dont think presets are valuable, we dont own the Entra URLs, and we should not hardcode those URLs in our codebase. @copilot what do you think about the comments in #352 (review) |
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ated files - Keep static BotTokenClient.BotScope unchanged (avoids breaking change) - Add ActiveBotScope instance property for per-cloud scope configuration - Remove CLAUDE.md, Claude-KB.md, and .gitignore session file entries that were unrelated to sovereign cloud support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- CloudEnvironmentTests: ClientCredentials cloud property defaults and assignment - BotTokenClientTests: ActiveBotScope defaults, overrides, and usage in GetAsync - TeamsValidationSettingsTests: sovereign cloud issuers, JWKS, login endpoints, tenant-specific URLs, and audience handling Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
a548dc5 to
ffbc525
Compare
96871c5 to
cbd96f9
Compare
There was a problem hiding this comment.
Pull request overview
Adds sovereign cloud (GCCH, DoD, China/21Vianet) configurability to the Teams SDK by introducing a CloudEnvironment abstraction and threading it through auth/token clients, app construction, and configuration/hosting extensions so previously hardcoded endpoints can be selected or overridden.
Changes:
- Introduces
CloudEnvironmentpresets (Public,USGov,USGovDoD,China) plusFromName()andWithOverrides()to centralize cloud-specific endpoints. - Updates auth/token flows to use cloud-specific endpoints/scopes (e.g., configurable Bot Framework scope and token service base URL).
- Adds configuration + hosting resolution (
TeamsSettings.ResolveCloud()) and new tests covering cloud-specific behavior.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.cs | New centralized cloud endpoint bundle with presets + name resolution + overrides. |
| Libraries/Microsoft.Teams.Api/Auth/ClientCredentials.cs | Uses cloud login endpoint + tenant for client-credentials token acquisition. |
| Libraries/Microsoft.Teams.Api/Clients/BotTokenClient.cs | Adds overridable bot scope (ActiveBotScope) used for token acquisition. |
| Libraries/Microsoft.Teams.Api/Clients/UserTokenClient.cs | Makes token service base URL configurable via TokenServiceUrl. |
| Libraries/Microsoft.Teams.Api/Clients/BotSignInClient.cs | Makes token service base URL configurable via TokenServiceUrl. |
| Libraries/Microsoft.Teams.Apps/AppOptions.cs | Adds Cloud option to pass cloud environment programmatically. |
| Libraries/Microsoft.Teams.Apps/App.cs | Applies cloud settings to API sub-clients (bot scope + token service URLs). |
| Libraries/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs | Adds Cloud + per-endpoint overrides and resolves to a CloudEnvironment. |
| Libraries/Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs | Resolves cloud from config/programmatic options and wires into credentials/options. |
| Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/HostApplicationBuilder.cs | Resolves cloud from config for token validation setup. |
| Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs | Makes validation settings cloud-aware (OpenID metadata URL, issuers, Entra issuer derivation). |
| Tests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.cs | New unit tests validating presets, overrides, and name resolution. |
| Tests/Microsoft.Teams.Api.Tests/Clients/BotTokenClientTests.cs | Adds coverage for ActiveBotScope behavior and usage. |
| Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/Extensions/TeamsValidationSettingsTests.cs | New tests validating cloud-aware token validation settings behavior. |
Comments suppressed due to low confidence (1)
Libraries/Microsoft.Teams.Apps/App.cs:60
AppOptions.Cloudis applied to the API clients (bot scope + token service URLs), but it isn’t applied toClientCredentials. In the programmatic usage shown in the PR description (Cloud = CloudEnvironment.USGov+Credentials = new ClientCredentials(...)),ClientCredentials.Cloudwill remainPublicand token acquisition will still hitlogin.microsoftonline.com. Consider propagatingcloudintoClientCredentialswhenCredentialsis aClientCredentialsinstance (or otherwise document/guard against this mismatch).
var cloud = options?.Cloud ?? CloudEnvironment.Public;
Logger = options?.Logger ?? new ConsoleLogger();
Storage = options?.Storage ?? new LocalStorage<object>();
Credentials = options?.Credentials;
Plugins = options?.Plugins ?? [];
OAuth = options?.OAuth ?? new OAuthSettings();
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
...osoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs
Show resolved
Hide resolved
...osoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/Extensions/TeamsValidationSettings.cs
Show resolved
Hide resolved
...ns/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs
Outdated
Show resolved
Hide resolved
...Microsoft.Teams.Extensions.Hosting/Microsoft.Teams.Apps.Extensions/HostApplicationBuilder.cs
Show resolved
Hide resolved
...ns/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs
Show resolved
Hide resolved
...ns/Microsoft.Teams.Extensions.Configuration/Microsoft.Teams.Apps.Extensions/TeamsSettings.cs
Show resolved
Hide resolved
cbd96f9 to
3e8c7b1
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var cloud = options?.Cloud ?? CloudEnvironment.Public; | ||
|
|
There was a problem hiding this comment.
App picks the cloud from options.Cloud only. If a caller sets ClientCredentials.Cloud but forgets to also set AppOptions.Cloud, the app will configure Api.Bots.Token.ActiveBotScope/token service URLs for the public cloud while ClientCredentials.Resolve() uses the sovereign login endpoint/tenant, leading to inconsistent token acquisition and hard-to-diagnose auth failures. Consider deriving cloud from options.Credentials when it is a ClientCredentials (and options.Cloud is null), and/or enforcing that options.Cloud and ClientCredentials.Cloud stay in sync (e.g., set one from the other).
| var cloud = options?.Cloud ?? CloudEnvironment.Public; | |
| var clientCredentials = options?.Credentials as ClientCredentials; | |
| var optionsCloud = options?.Cloud; | |
| var credentialsCloud = clientCredentials?.Cloud; | |
| if (optionsCloud is not null && credentialsCloud is not null && optionsCloud != credentialsCloud) | |
| { | |
| throw new ArgumentException("AppOptions.Cloud must match ClientCredentials.Cloud when both are specified.", nameof(options)); | |
| } | |
| var cloud = optionsCloud ?? credentialsCloud ?? CloudEnvironment.Public; |
| public async Task<ITokenResponse> Resolve(IHttpClient client, string[] scopes, CancellationToken cancellationToken = default) | ||
| { | ||
| var tenantId = TenantId ?? "botframework.com"; | ||
| var tenantId = TenantId ?? Cloud.LoginTenant; |
There was a problem hiding this comment.
TenantId ?? Cloud.LoginTenant treats an empty/whitespace TenantId as a real tenant value, producing invalid token URLs like ...//oauth2/v2.0/token. Consider using string.IsNullOrWhiteSpace(TenantId) to fall back to Cloud.LoginTenant (and similarly treating whitespace-only values as unset).
| var tenantId = TenantId ?? Cloud.LoginTenant; | |
| var tenantId = string.IsNullOrWhiteSpace(TenantId) ? Cloud.LoginTenant : TenantId; |
| 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"; |
There was a problem hiding this comment.
GetTenantSpecificOpenIdMetadataUrl uses tenantId ?? "common", so callers passing an empty string (or whitespace) will generate an invalid URL segment instead of defaulting to common. Consider normalizing with string.IsNullOrWhiteSpace(tenantId) and defaulting to common in that case.
| return $"{LoginEndpoint}/{tenantId ?? "common"}/v2.0/.well-known/openid-configuration"; | |
| var normalizedTenantId = string.IsNullOrWhiteSpace(tenantId) ? "common" : tenantId; | |
| return $"{LoginEndpoint}/{normalizedTenantId}/v2.0/.well-known/openid-configuration"; |
| @@ -29,13 +46,13 @@ public IEnumerable<string> GetValidIssuersForTenant(string? tenantId) | |||
| var validIssuers = new List<string>(); | |||
| if (!string.IsNullOrEmpty(tenantId)) | |||
There was a problem hiding this comment.
GetValidIssuersForTenant only checks string.IsNullOrEmpty(tenantId), so whitespace tenant IDs will still be used to build issuer URLs (and could create invalid issuers). Consider using string.IsNullOrWhiteSpace(tenantId) for the guard.
| if (!string.IsNullOrEmpty(tenantId)) | |
| if (!string.IsNullOrWhiteSpace(tenantId)) |
| 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<Token.Response>(req, token); |
There was a problem hiding this comment.
TokenServiceUrl is concatenated into request URLs without normalization. If TokenServiceUrl is configured with a trailing slash, this produces a //api/... path segment. Consider trimming trailing slashes in the setter or before composing the request URL.
| 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}" | ||
| ); |
There was a problem hiding this comment.
TokenServiceUrl is concatenated into request URLs without normalization. If a consumer sets TokenServiceUrl with a trailing slash, the resulting request path will contain //api/.... Consider trimming trailing slashes in the setter or when building the request URL.
| // 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); | ||
| } |
There was a problem hiding this comment.
In the AddTeamsCore(IHostApplicationBuilder, AppBuilder) overload, the resolved cloud is not applied to the AppBuilder/AppOptions (only ClientCredentials.Cloud is set). This means App will still default to CloudEnvironment.Public and configure Api.Bots.Token.ActiveBotScope/token service URLs for public cloud, creating a mismatch with the sovereign ClientCredentials.Cloud. Consider threading the resolved cloud into the AppBuilder options (e.g., add an AddCloud(CloudEnvironment) builder API, or build an AppOptions with Cloud = cloud and use the AddTeamsCore(builder, AppOptions) overload) so config-based cloud selection works in this overload.
Summary
CloudEnvironmentclass (Microsoft.Teams.Api.Auth) bundling all cloud-specific service endpoints with predefined static instances:Public,USGov(GCCH),USGovDoD, andChina(21Vianet)ClientCredentials,BotTokenClient,UserTokenClient,BotSignInClient,App,TeamsValidationSettings, and DI host builders so all previously hardcoded endpoints are configurable per cloudCloudproperty toAppOptions(programmatic) andTeamsSettings(appsettings.json) for easy configurationTeamsSettings(e.g.LoginEndpoint,LoginTenant) for scenarios requiring per-endpoint customization — such as China single-tenant bots that need a tenant-specific login URLUsage
Named cloud preset (appsettings.json):
{ "Teams": { "ClientId": "...", "ClientSecret": "...", "Cloud": "USGov" } }Per-endpoint overrides (appsettings.json):
{ "Teams": { "ClientId": "...", "ClientSecret": "...", "TenantId": "your-tenant", "Cloud": "China", "LoginTenant": "your-tenant-id" } }Programmatic:
Valid cloud names:
Public(default),USGov,USGovDoD,ChinaOverride properties:
LoginEndpoint,LoginTenant,BotScope,TokenServiceUrl,OpenIdMetadataUrl,TokenIssuer,ChannelService,OAuthRedirectUrlFiles changed
Libraries/Microsoft.Teams.Api/Auth/CloudEnvironment.csTests/Microsoft.Teams.Api.Tests/Auth/CloudEnvironmentTests.csLibraries/Microsoft.Teams.Api/Auth/ClientCredentials.csLibraries/Microsoft.Teams.Api/Clients/BotTokenClient.csLibraries/Microsoft.Teams.Api/Clients/UserTokenClient.csLibraries/Microsoft.Teams.Api/Clients/BotSignInClient.csLibraries/Microsoft.Teams.Apps/AppOptions.csLibraries/Microsoft.Teams.Apps/App.csTeamsSettings.cs(Extensions.Configuration) — endpoint override properties +ResolveCloud()HostApplicationBuilder.cs(Extensions.Hosting) — unified cloud resolution viaResolveCloud()HostApplicationBuilder.cs(Plugins.AspNetCore) — unified cloud resolution viaResolveCloud()TeamsValidationSettings.cs(Plugins.AspNetCore)Test plan
dotnet build— 0 errorsdotnet test— all existing + new tests passCloudEnvironment.PublicURLs match current hardcoded values (backward compat)WithOverrides()with all nulls returns same instance (no allocation)CloudEnvironment.USGovURLs match BF GCCH docsCloudEnvironment.ChinaURLs match BF China docsSamples.Echo) starts correctly with no cloud config (defaults to Public)🤖 Generated with Claude Code