Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/api/src/microsoft_teams/api/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
]
113 changes: 113 additions & 0 deletions packages/api/src/microsoft_teams/api/auth/cloud_environment.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
)
25 changes: 17 additions & 8 deletions packages/api/src/microsoft_teams/api/clients/bot/token_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.
Expand All @@ -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):
Expand All @@ -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"},
)
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading