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