From f0ea2e446ace4ed8c977b8de9c9f7bff84c2b464 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 25 Mar 2026 13:18:51 -0700 Subject: [PATCH] Add sovereign cloud support (GCCH, DoD, China) Introduce CloudEnvironment frozen dataclass with predefined presets (PUBLIC, US_GOV, US_GOV_DOD, CHINA) bundling all cloud-specific service endpoints. Thread cloud environment through App, TokenManager, BotTokenClient, TokenValidator, and ApiClientSettings so previously hardcoded endpoints are configurable per cloud. Supports programmatic configuration via AppOptions cloud parameter or CLOUD environment variable. Includes comprehensive tests for CloudEnvironment presets, from_name(), and with_overrides(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/microsoft_teams/api/auth/__init__.py | 16 ++ .../api/auth/cloud_environment.py | 113 ++++++++++++++ .../api/clients/api_client_settings.py | 20 ++- .../api/clients/bot/token_client.py | 25 +++- .../api/tests/unit/test_cloud_environment.py | 140 ++++++++++++++++++ packages/apps/src/microsoft_teams/apps/app.py | 11 +- .../apps/auth/jwt_middleware.py | 7 +- .../apps/auth/token_validator.py | 37 +++-- .../microsoft_teams/apps/http/http_server.py | 6 +- .../apps/src/microsoft_teams/apps/options.py | 12 ++ .../src/microsoft_teams/apps/token_manager.py | 12 +- 11 files changed, 368 insertions(+), 31 deletions(-) create mode 100644 packages/api/src/microsoft_teams/api/auth/cloud_environment.py create mode 100644 packages/api/tests/unit/test_cloud_environment.py diff --git a/packages/api/src/microsoft_teams/api/auth/__init__.py b/packages/api/src/microsoft_teams/api/auth/__init__.py index b177eb4b..8d3ec484 100644 --- a/packages/api/src/microsoft_teams/api/auth/__init__.py +++ b/packages/api/src/microsoft_teams/api/auth/__init__.py @@ -4,6 +4,15 @@ """ from .caller import CallerIds, CallerType +from .cloud_environment import ( + CHINA, + PUBLIC, + US_GOV, + US_GOV_DOD, + CloudEnvironment, + from_name, + with_overrides, +) from .credentials import ( ClientCredentials, Credentials, @@ -17,12 +26,19 @@ __all__ = [ "CallerIds", "CallerType", + "CHINA", + "CloudEnvironment", "ClientCredentials", "Credentials", "FederatedIdentityCredentials", + "from_name", "ManagedIdentityCredentials", + "PUBLIC", "TokenCredentials", "TokenProtocol", "JsonWebToken", "JsonWebTokenPayload", + "US_GOV", + "US_GOV_DOD", + "with_overrides", ] diff --git a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py new file mode 100644 index 00000000..acc834b3 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py @@ -0,0 +1,113 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from dataclasses import dataclass, replace +from typing import Optional + + +@dataclass(frozen=True) +class CloudEnvironment: + """ + Bundles all cloud-specific service endpoints for a given Azure environment. + Use predefined instances (PUBLIC, US_GOV, US_GOV_DOD, CHINA) + or construct a custom one via with_overrides(). + """ + + login_endpoint: str + """The Azure AD login endpoint (e.g. "https://login.microsoftonline.com").""" + login_tenant: str + """The default multi-tenant login tenant (e.g. "botframework.com").""" + bot_scope: str + """The Bot Framework OAuth scope (e.g. "https://api.botframework.com/.default").""" + token_service_url: str + """The Bot Framework token service base URL (e.g. "https://token.botframework.com").""" + openid_metadata_url: str + """The OpenID metadata URL for token validation.""" + token_issuer: str + """The token issuer for Bot Framework tokens (e.g. "https://api.botframework.com").""" + channel_service: str + """The channel service URL. Empty for public cloud; set for sovereign clouds.""" + oauth_redirect_url: str + """The OAuth redirect URL (e.g. "https://token.botframework.com/.auth/web/redirect").""" + + +PUBLIC = CloudEnvironment( + login_endpoint="https://login.microsoftonline.com", + login_tenant="botframework.com", + bot_scope="https://api.botframework.com/.default", + token_service_url="https://token.botframework.com", + openid_metadata_url="https://login.botframework.com/v1/.well-known/openidconfiguration", + token_issuer="https://api.botframework.com", + channel_service="", + oauth_redirect_url="https://token.botframework.com/.auth/web/redirect", +) +"""Microsoft public (commercial) cloud.""" + +US_GOV = CloudEnvironment( + login_endpoint="https://login.microsoftonline.us", + login_tenant="MicrosoftServices.onmicrosoft.us", + bot_scope="https://api.botframework.us/.default", + token_service_url="https://tokengcch.botframework.azure.us", + openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + token_issuer="https://api.botframework.us", + channel_service="https://botframework.azure.us", + oauth_redirect_url="https://tokengcch.botframework.azure.us/.auth/web/redirect", +) +"""US Government Community Cloud High (GCCH).""" + +US_GOV_DOD = CloudEnvironment( + login_endpoint="https://login.microsoftonline.us", + login_tenant="MicrosoftServices.onmicrosoft.us", + bot_scope="https://api.botframework.us/.default", + token_service_url="https://apiDoD.botframework.azure.us", + openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration", + token_issuer="https://api.botframework.us", + channel_service="https://botframework.azure.us", + oauth_redirect_url="https://apiDoD.botframework.azure.us/.auth/web/redirect", +) +"""US Government Department of Defense (DoD).""" + +CHINA = CloudEnvironment( + login_endpoint="https://login.partner.microsoftonline.cn", + login_tenant="microsoftservices.partner.onmschina.cn", + bot_scope="https://api.botframework.azure.cn/.default", + token_service_url="https://token.botframework.azure.cn", + openid_metadata_url="https://login.botframework.azure.cn/v1/.well-known/openidconfiguration", + token_issuer="https://api.botframework.azure.cn", + channel_service="https://botframework.azure.cn", + oauth_redirect_url="https://token.botframework.azure.cn/.auth/web/redirect", +) +"""China cloud (21Vianet).""" + +_CLOUD_ENVIRONMENTS: dict[str, CloudEnvironment] = { + "public": PUBLIC, + "usgov": US_GOV, + "usgovdod": US_GOV_DOD, + "china": CHINA, +} + + +def from_name(name: str) -> CloudEnvironment: + """ + Resolve a cloud environment name (case-insensitive) to its corresponding instance. + Valid names: "Public", "USGov", "USGovDoD", "China". + """ + env = _CLOUD_ENVIRONMENTS.get(name.lower()) + if env is None: + raise ValueError( + f"Unknown cloud environment: '{name}'. Valid values are: Public, USGov, USGovDoD, China." + ) + return env + + +def with_overrides(base: CloudEnvironment, **overrides: Optional[str]) -> CloudEnvironment: + """ + Create a new CloudEnvironment by applying non-None overrides on top of a base. + Returns the same instance if all overrides are None (no allocation). + """ + filtered = {k: v for k, v in overrides.items() if v is not None} + if not filtered: + return base + return replace(base, **filtered) diff --git a/packages/api/src/microsoft_teams/api/clients/api_client_settings.py b/packages/api/src/microsoft_teams/api/clients/api_client_settings.py index b64faac1..871dead5 100644 --- a/packages/api/src/microsoft_teams/api/clients/api_client_settings.py +++ b/packages/api/src/microsoft_teams/api/clients/api_client_settings.py @@ -3,9 +3,14 @@ Licensed under the MIT License. """ +from __future__ import annotations + import os from dataclasses import dataclass -from typing import Optional +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: + from ..auth.cloud_environment import CloudEnvironment @dataclass @@ -26,12 +31,16 @@ class ApiClientSettings: DEFAULT_API_CLIENT_SETTINGS = ApiClientSettings() -def merge_api_client_settings(api_client_settings: Optional[ApiClientSettings]) -> ApiClientSettings: +def merge_api_client_settings( + api_client_settings: Optional[ApiClientSettings] = None, + cloud: Optional["CloudEnvironment"] = None, +) -> ApiClientSettings: """ Merge API client settings with environment variables and defaults. Args: api_client_settings: Optional API client settings to merge. + cloud: Optional cloud environment for default oauth_url. Returns: Merged API client settings. @@ -41,5 +50,10 @@ def merge_api_client_settings(api_client_settings: Optional[ApiClientSettings]) # Check for environment variable override env_oauth_url = os.environ.get("OAUTH_URL") + default_oauth_url = cloud.token_service_url if cloud else DEFAULT_API_CLIENT_SETTINGS.oauth_url - return ApiClientSettings(oauth_url=env_oauth_url if env_oauth_url else api_client_settings.oauth_url) + return ApiClientSettings( + oauth_url=api_client_settings.oauth_url + if api_client_settings.oauth_url != DEFAULT_API_CLIENT_SETTINGS.oauth_url + else (env_oauth_url or default_oauth_url) + ) diff --git a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py index a003002b..a7034ac5 100644 --- a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py +++ b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py @@ -3,17 +3,23 @@ Licensed under the MIT License. """ +from __future__ import annotations + import inspect -from typing import Literal, Optional, Union +from typing import TYPE_CHECKING, Literal, Optional, Union from microsoft_teams.api.auth.credentials import ClientCredentials from microsoft_teams.common.http import Client, ClientOptions from pydantic import BaseModel from ...auth import Credentials, TokenCredentials +from ...auth.cloud_environment import PUBLIC from ..api_client_settings import ApiClientSettings, merge_api_client_settings from ..base_client import BaseClient +if TYPE_CHECKING: + from ...auth.cloud_environment import CloudEnvironment + class GetBotTokenResponse(BaseModel): """Response model for bot token requests.""" @@ -44,15 +50,18 @@ def __init__( self, options: Union[Client, ClientOptions, None] = None, api_client_settings: Optional[ApiClientSettings] = None, + cloud: Optional[CloudEnvironment] = None, ) -> None: """Initialize the bot token client. Args: options: Optional Client or ClientOptions instance. api_client_settings: Optional API client settings. + cloud: Optional cloud environment for sovereign cloud support. """ super().__init__(options) - self._api_client_settings = merge_api_client_settings(api_client_settings) + self._cloud = cloud or PUBLIC + self._api_client_settings = merge_api_client_settings(api_client_settings, cloud) async def get(self, credentials: Credentials) -> GetBotTokenResponse: """Get a bot token. @@ -65,7 +74,7 @@ async def get(self, credentials: Credentials) -> GetBotTokenResponse: """ if isinstance(credentials, TokenCredentials): token = credentials.token( - "https://api.botframework.com/.default", + self._cloud.bot_scope, credentials.tenant_id, ) if inspect.isawaitable(token): @@ -81,14 +90,14 @@ async def get(self, credentials: Credentials) -> GetBotTokenResponse: "Bot token client currently only supports Credentials with secrets." ) - tenant_id = credentials.tenant_id or "botframework.com" + tenant_id = credentials.tenant_id or self._cloud.login_tenant res = await self.http.post( - f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", + f"{self._cloud.login_endpoint}/{tenant_id}/oauth2/v2.0/token", data={ "grant_type": "client_credentials", "client_id": credentials.client_id, "client_secret": credentials.client_secret, - "scope": "https://api.botframework.com/.default", + "scope": self._cloud.bot_scope, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) @@ -122,9 +131,9 @@ async def get_graph(self, credentials: Credentials) -> GetBotTokenResponse: "Bot token client currently only supports Credentials with secrets." ) - tenant_id = credentials.tenant_id or "botframework.com" + tenant_id = credentials.tenant_id or self._cloud.login_tenant res = await self.http.post( - f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token", + f"{self._cloud.login_endpoint}/{tenant_id}/oauth2/v2.0/token", data={ "grant_type": "client_credentials", "client_id": credentials.client_id, diff --git a/packages/api/tests/unit/test_cloud_environment.py b/packages/api/tests/unit/test_cloud_environment.py new file mode 100644 index 00000000..2b56a2aa --- /dev/null +++ b/packages/api/tests/unit/test_cloud_environment.py @@ -0,0 +1,140 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import dataclasses + +import pytest + +from microsoft_teams.api.auth.cloud_environment import ( + CHINA, + PUBLIC, + US_GOV, + US_GOV_DOD, + CloudEnvironment, + from_name, + with_overrides, +) + + +@pytest.mark.unit +class TestCloudEnvironmentPresets: + def test_public_has_correct_endpoints(self): + assert PUBLIC.login_endpoint == "https://login.microsoftonline.com" + assert PUBLIC.login_tenant == "botframework.com" + assert PUBLIC.bot_scope == "https://api.botframework.com/.default" + assert PUBLIC.token_service_url == "https://token.botframework.com" + assert PUBLIC.openid_metadata_url == "https://login.botframework.com/v1/.well-known/openidconfiguration" + assert PUBLIC.token_issuer == "https://api.botframework.com" + assert PUBLIC.channel_service == "" + assert PUBLIC.oauth_redirect_url == "https://token.botframework.com/.auth/web/redirect" + + def test_us_gov_has_correct_endpoints(self): + assert US_GOV.login_endpoint == "https://login.microsoftonline.us" + assert US_GOV.login_tenant == "MicrosoftServices.onmicrosoft.us" + assert US_GOV.bot_scope == "https://api.botframework.us/.default" + assert US_GOV.token_service_url == "https://tokengcch.botframework.azure.us" + assert US_GOV.openid_metadata_url == "https://login.botframework.azure.us/v1/.well-known/openidconfiguration" + assert US_GOV.token_issuer == "https://api.botframework.us" + assert US_GOV.channel_service == "https://botframework.azure.us" + assert US_GOV.oauth_redirect_url == "https://tokengcch.botframework.azure.us/.auth/web/redirect" + + def test_us_gov_dod_has_correct_endpoints(self): + assert US_GOV_DOD.login_endpoint == "https://login.microsoftonline.us" + assert US_GOV_DOD.token_service_url == "https://apiDoD.botframework.azure.us" + assert US_GOV_DOD.token_issuer == "https://api.botframework.us" + assert US_GOV_DOD.channel_service == "https://botframework.azure.us" + + def test_china_has_correct_endpoints(self): + assert CHINA.login_endpoint == "https://login.partner.microsoftonline.cn" + assert CHINA.login_tenant == "microsoftservices.partner.onmschina.cn" + assert CHINA.bot_scope == "https://api.botframework.azure.cn/.default" + assert CHINA.token_service_url == "https://token.botframework.azure.cn" + assert CHINA.token_issuer == "https://api.botframework.azure.cn" + assert CHINA.channel_service == "https://botframework.azure.cn" + + def test_presets_are_frozen(self): + with pytest.raises(dataclasses.FrozenInstanceError): + PUBLIC.login_endpoint = "https://modified.example.com" # type: ignore[misc] + + +@pytest.mark.unit +class TestFromName: + @pytest.mark.parametrize( + "name,expected", + [ + ("Public", PUBLIC), + ("public", PUBLIC), + ("PUBLIC", PUBLIC), + ("USGov", US_GOV), + ("usgov", US_GOV), + ("USGovDoD", US_GOV_DOD), + ("usgovdod", US_GOV_DOD), + ("China", CHINA), + ("china", CHINA), + ], + ) + def test_resolves_correctly(self, name: str, expected: CloudEnvironment): + assert from_name(name) is expected + + @pytest.mark.parametrize("name", ["invalid", "", "Azure"]) + def test_raises_for_unknown_name(self, name: str): + with pytest.raises(ValueError, match="Unknown cloud environment"): + from_name(name) + + +@pytest.mark.unit +class TestWithOverrides: + def test_returns_same_instance_when_no_overrides(self): + result = with_overrides(PUBLIC) + assert result is PUBLIC + + def test_returns_same_instance_when_all_none(self): + result = with_overrides(PUBLIC, login_endpoint=None, login_tenant=None) + assert result is PUBLIC + + def test_replaces_single_property(self): + result = with_overrides(PUBLIC, login_tenant="my-tenant-id") + assert result is not PUBLIC + assert result.login_tenant == "my-tenant-id" + assert result.login_endpoint == PUBLIC.login_endpoint + assert result.bot_scope == PUBLIC.bot_scope + + def test_replaces_multiple_properties(self): + result = with_overrides( + CHINA, + login_endpoint="https://custom.login.cn", + login_tenant="custom-tenant", + token_service_url="https://custom.token.cn", + ) + assert result.login_endpoint == "https://custom.login.cn" + assert result.login_tenant == "custom-tenant" + assert result.token_service_url == "https://custom.token.cn" + assert result.bot_scope == CHINA.bot_scope + + def test_replaces_all_properties(self): + result = with_overrides( + PUBLIC, + login_endpoint="a", + login_tenant="b", + bot_scope="c", + token_service_url="d", + openid_metadata_url="e", + token_issuer="f", + channel_service="g", + oauth_redirect_url="h", + ) + assert result.login_endpoint == "a" + assert result.login_tenant == "b" + assert result.bot_scope == "c" + assert result.token_service_url == "d" + assert result.openid_metadata_url == "e" + assert result.token_issuer == "f" + assert result.channel_service == "g" + assert result.oauth_redirect_url == "h" + + def test_result_is_frozen(self): + result = with_overrides(PUBLIC, login_tenant="test") + with pytest.raises(dataclasses.FrozenInstanceError): + result.login_tenant = "modified" # type: ignore[misc] diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 47b21a9d..15054a63 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -33,6 +33,8 @@ from .app_oauth import OauthHandlers from .app_plugins import PluginProcessor from .app_process import ActivityProcessor +from microsoft_teams.api.auth.cloud_environment import PUBLIC, from_name as cloud_from_name + from .auth import TokenValidator from .auth.remote_function_jwt_middleware import validate_remote_function_request from .container import Container @@ -74,6 +76,10 @@ class App(ActivityHandlerMixin): def __init__(self, **options: Unpack[AppOptions]): self.options = InternalAppOptions.from_typeddict(options) + # Resolve cloud environment from options or CLOUD env var + cloud_env_name = os.getenv("CLOUD") + self.cloud = self.options.cloud or (cloud_from_name(cloud_env_name) if cloud_env_name else PUBLIC) + self.storage = self.options.storage or LocalStorage() self.http_client = Client( @@ -89,6 +95,7 @@ def __init__(self, **options: Unpack[AppOptions]): self._token_manager = TokenManager( credentials=self.credentials, + cloud=self.cloud, ) self.container = Container() @@ -151,7 +158,7 @@ def __init__(self, **options: Unpack[AppOptions]): self.entra_token_validator: Optional[TokenValidator] = None if self.credentials and hasattr(self.credentials, "client_id"): self.entra_token_validator = TokenValidator.for_entra( - self.credentials.client_id, self.credentials.tenant_id + self.credentials.client_id, self.credentials.tenant_id, cloud=self.cloud ) @property @@ -196,7 +203,7 @@ async def initialize(self) -> None: # Initialize HttpServer (JWT validation + default /api/messages route) self.server.on_request = self._process_activity_event - self.server.initialize(credentials=self.credentials, skip_auth=self.options.skip_auth) + self.server.initialize(credentials=self.credentials, skip_auth=self.options.skip_auth, cloud=self.cloud) self._initialized = True logger.info("Teams app initialized successfully") diff --git a/packages/apps/src/microsoft_teams/apps/auth/jwt_middleware.py b/packages/apps/src/microsoft_teams/apps/auth/jwt_middleware.py index e56a8a14..622c5a30 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/jwt_middleware.py +++ b/packages/apps/src/microsoft_teams/apps/auth/jwt_middleware.py @@ -4,10 +4,11 @@ """ import logging -from typing import Awaitable, Callable +from typing import Awaitable, Callable, Optional import jwt from fastapi import HTTPException, Request, Response +from microsoft_teams.api.auth.cloud_environment import CloudEnvironment from microsoft_teams.api.auth.json_web_token import JsonWebToken from .token_validator import TokenValidator @@ -18,6 +19,7 @@ def create_jwt_validation_middleware( app_id: str, paths: list[str], + cloud: Optional[CloudEnvironment] = None, ): """ Create JWT validation middleware instance. @@ -25,12 +27,13 @@ def create_jwt_validation_middleware( Args: app_id: Bot's Microsoft App ID for audience validation paths: List of paths to validate + cloud: Optional cloud environment for sovereign cloud support Returns: Middleware function that can be added to FastAPI app """ # Create service token validator - token_validator = TokenValidator.for_service(app_id) + token_validator = TokenValidator.for_service(app_id, cloud=cloud) async def middleware(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: """JWT validation middleware function.""" diff --git a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py index 0a0f1ea5..7615770c 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py +++ b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py @@ -3,11 +3,15 @@ Licensed under the MIT License. """ +from __future__ import annotations + import logging +import re from dataclasses import dataclass from typing import Any, Dict, List, Optional import jwt +from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment JWT_LEEWAY_SECONDS = 300 # Allowable clock skew when validating JWTs @@ -48,42 +52,57 @@ def __init__(self, jwt_validation_options: JwtValidationOptions): # ----- Factory constructors ----- @classmethod - def for_service(cls, app_id: str, service_url: Optional[str] = None) -> "TokenValidator": + def for_service( + cls, + app_id: str, + service_url: Optional[str] = None, + cloud: Optional[CloudEnvironment] = None, + ) -> TokenValidator: """Create a validator for Bot Framework service tokens. Reference: https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication Args: app_id: The bot's Microsoft App ID (used for audience validation) - service_url: Optional service URL to validate against token claims""" + service_url: Optional service URL to validate against token claims + cloud: Optional cloud environment for sovereign cloud support + """ + env = cloud or PUBLIC + jwks_keys_uri = re.sub(r"/openidconfiguration$", "/keys", env.openid_metadata_url) options = JwtValidationOptions( - valid_issuers=["https://api.botframework.com"], + valid_issuers=[env.token_issuer], valid_audiences=[app_id, f"api://{app_id}"], - jwks_uri="https://login.botframework.com/v1/.well-known/keys", + jwks_uri=jwks_keys_uri, service_url=service_url, ) return cls(options) @classmethod - def for_entra(cls, app_id: str, tenant_id: Optional[str], scope: Optional[str] = None) -> "TokenValidator": + def for_entra( + cls, + app_id: str, + tenant_id: Optional[str], + scope: Optional[str] = None, + cloud: Optional[CloudEnvironment] = None, + ) -> TokenValidator: """Create a validator for Entra ID tokens. Args: app_id: The app's Microsoft App ID (used for audience validation) tenant_id: The Azure AD tenant ID scope: Optional scope that must be present in the token - + cloud: Optional cloud environment for sovereign cloud support """ - + env = cloud or PUBLIC valid_issuers: List[str] = [] if tenant_id: - valid_issuers.append(f"https://login.microsoftonline.com/{tenant_id}/v2.0") + valid_issuers.append(f"{env.login_endpoint}/{tenant_id}/v2.0") tenant_id = tenant_id or "common" options = JwtValidationOptions( valid_issuers=valid_issuers, valid_audiences=[app_id, f"api://{app_id}"], - jwks_uri=f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys", + jwks_uri=f"{env.login_endpoint}/{tenant_id}/discovery/v2.0/keys", scope=scope, ) return cls(options) diff --git a/packages/apps/src/microsoft_teams/apps/http/http_server.py b/packages/apps/src/microsoft_teams/apps/http/http_server.py index 4a9c226b..c28bd9c4 100644 --- a/packages/apps/src/microsoft_teams/apps/http/http_server.py +++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py @@ -11,6 +11,8 @@ from microsoft_teams.api.auth.json_web_token import JsonWebToken from pydantic import BaseModel +from microsoft_teams.api.auth.cloud_environment import CloudEnvironment + from ..auth import TokenValidator from ..events import ActivityEvent, CoreActivity from .adapter import HttpRequest, HttpResponse, HttpServerAdapter @@ -51,6 +53,7 @@ def initialize( self, credentials: Optional[Credentials] = None, skip_auth: bool = False, + cloud: Optional[CloudEnvironment] = None, ) -> None: """ Set up JWT validation and register the default POST /api/messages route. @@ -58,6 +61,7 @@ def initialize( Args: credentials: App credentials for JWT validation. skip_auth: Whether to skip JWT validation. + cloud: Optional cloud environment for sovereign cloud support. """ if self._initialized: return @@ -66,7 +70,7 @@ def initialize( app_id = getattr(credentials, "client_id", None) if credentials else None if app_id and not skip_auth: - self._token_validator = TokenValidator.for_service(app_id) + self._token_validator = TokenValidator.for_service(app_id, cloud=cloud) logger.debug("JWT validation enabled for /api/messages") self._adapter.register_route("POST", "/api/messages", self.handle_request) diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index 9a2b7927..f202b61c 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -9,6 +9,7 @@ from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union, cast from microsoft_teams.api import ApiClientSettings +from microsoft_teams.api.auth.cloud_environment import CloudEnvironment from microsoft_teams.common import Storage from typing_extensions import Unpack @@ -63,6 +64,15 @@ class AppOptions(TypedDict, total=False): and defaults to https://smba.trafficmanager.net/teams """ + # Cloud environment + cloud: Optional[CloudEnvironment] + """ + Cloud environment for sovereign cloud support. + Accepts a CloudEnvironment instance or uses CLOUD environment variable. + Valid env var values: "Public", "USGov", "USGovDoD", "China". + Defaults to PUBLIC (commercial cloud). + """ + @dataclass class InternalAppOptions: @@ -101,6 +111,8 @@ class InternalAppOptions: """ http_server_adapter: Optional[HttpServerAdapter] = None """Custom HTTP server adapter. Defaults to FastAPIAdapter if not provided.""" + cloud: Optional[CloudEnvironment] = None + """Cloud environment for sovereign cloud support.""" @classmethod def from_typeddict(cls, options: AppOptions) -> "InternalAppOptions": diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index 9dc84718..55274979 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -15,6 +15,7 @@ JsonWebToken, TokenProtocol, ) +from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment from microsoft_teams.api.auth.credentials import ( FederatedIdentityCredentials, ManagedIdentityCredentials, @@ -27,11 +28,8 @@ UserAssignedManagedIdentity, ) -BOT_TOKEN_SCOPE = "https://api.botframework.com/.default" GRAPH_TOKEN_SCOPE = "https://graph.microsoft.com/.default" -DEFAULT_TENANT_FOR_BOT_TOKEN = "botframework.com" DEFAULT_TENANT_FOR_GRAPH_TOKEN = "common" -DEFAULT_TOKEN_AUTHORITY = "https://login.microsoftonline.com/{tenant_id}" logger = logging.getLogger(__name__) @@ -42,15 +40,17 @@ class TokenManager: def __init__( self, credentials: Optional[Credentials], + cloud: Optional[CloudEnvironment] = None, ): self._credentials = credentials + self._cloud = cloud or PUBLIC self._confidential_clients_by_tenant: dict[str, ConfidentialClientApplication] = {} self._managed_identity_client: Optional[ManagedIdentityClient] = None async def get_bot_token(self) -> Optional[TokenProtocol]: """Refresh the bot authentication token.""" return await self._get_token( - BOT_TOKEN_SCOPE, tenant_id=self._resolve_tenant_id(None, DEFAULT_TENANT_FOR_BOT_TOKEN) + self._cloud.bot_scope, tenant_id=self._resolve_tenant_id(None, self._cloud.login_tenant) ) async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]: @@ -134,7 +134,7 @@ async def _get_token_with_federated_identity( confidential_client = ConfidentialClientApplication( credentials.client_id, client_credential={"client_assertion": mi_token}, - authority=DEFAULT_TOKEN_AUTHORITY.format(tenant_id=tenant_id), + authority=f"{self._cloud.login_endpoint}/{tenant_id}", ) token_res: dict[str, Any] = await asyncio.to_thread( @@ -205,7 +205,7 @@ def _get_confidential_client(self, credentials: ClientCredentials, tenant_id: st client: ConfidentialClientApplication = ConfidentialClientApplication( credentials.client_id, client_credential=credentials.client_secret, - authority=f"https://login.microsoftonline.com/{tenant_id}", + authority=f"{self._cloud.login_endpoint}/{tenant_id}", ) self._confidential_clients_by_tenant[tenant_id] = client return client