Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ requires-python = ">=3.12"
dependencies = [
"pydantic>=2.0.0",
"microsoft-teams-common",
"jwt>=1.3.1",
]

[tool.uv.sources]
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/microsoft/teams/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
Licensed under the MIT License.
"""

from .auth import * # noqa: F403
from .auth import __all__ as auth_all
from .clients import * # noqa: F403
from .clients import __all__ as clients_all
from .models import * # noqa: F403
from .models import __all__ as models_all

# Combine all exports from submodules
__all__ = [
*auth_all,
*clients_all,
*models_all,
]
20 changes: 20 additions & 0 deletions packages/api/src/microsoft/teams/api/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from .caller import CallerIds, CallerType
from .credentials import ClientCredentials, Credentials, TokenCredentials
from .json_web_token import JsonWebToken, JsonWebTokenPayload
from .token import TokenProtocol

__all__ = [
"CallerIds",
"CallerType",
"ClientCredentials",
"Credentials",
"TokenCredentials",
"TokenProtocol",
"JsonWebToken",
"JsonWebTokenPayload",
]
18 changes: 18 additions & 0 deletions packages/api/src/microsoft/teams/api/auth/caller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from enum import Enum
from typing import Literal


class CallerIds(str, Enum):
"""Enum for caller ID types."""

AZURE = "azure"
GOV = "gov"
BOT = "bot"


CallerType = Literal["azure", "gov", "bot"]
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from typing import Awaitable, Callable, Optional, Union

from ..custom_base_model import CustomBaseModel
from ..models import CustomBaseModel


class ClientCredentials(CustomBaseModel):
Expand Down
129 changes: 129 additions & 0 deletions packages/api/src/microsoft/teams/api/auth/json_web_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

import time
from typing import Optional, Union

import jwt
from pydantic import BaseModel, ConfigDict

from .caller import CallerIds, CallerType
from .token import TokenProtocol


class JsonWebTokenPayload(BaseModel):
"""JWT payload with additional Teams-specific fields."""

model_config = ConfigDict(extra="allow")

aud: Optional[Union[str, list[str]]] = None
iss: Optional[str] = None
exp: Optional[int] = None
kid: Optional[str] = None
appid: Optional[str] = None
app_displayname: Optional[str] = None
tid: Optional[str] = None
version: Optional[str] = None
serviceurl: Optional[str] = None


class JsonWebToken(TokenProtocol):
"""JSON Web Token implementation for Teams authentication."""

def __init__(self, value: str):
"""
Initialize JWT from token string.

Args:
value: The JWT token string.
"""
self._value = value
# Decode without verification for payload extraction
jwt_instance = jwt.JWT()
self._payload = JsonWebTokenPayload(**jwt_instance.decode(value, do_verify=False, do_time_check=False))

@property
def audience(self) -> Optional[Union[str, list[str]]]:
"""The token audience."""
return self._payload.aud

@property
def issuer(self) -> Optional[str]:
"""The token issuer."""
return self._payload.iss

@property
def key_id(self) -> Optional[str]:
"""The key ID."""
return self._payload.kid

@property
def app_id(self) -> str:
"""The app ID."""
return self._payload.appid or ""

@property
def app_display_name(self) -> Optional[str]:
"""The app display name."""
return self._payload.app_displayname

@property
def tenant_id(self) -> Optional[str]:
"""The tenant ID."""
return self._payload.tid

@property
def version(self) -> Optional[str]:
"""The token version."""
return self._payload.version

@property
def service_url(self) -> str:
"""The service URL to send responses to."""
url = self._payload.serviceurl or "https://smba.trafficmanager.net/teams"

if url.endswith("/"):
url = url[:-1]

return url

@property
def from_(self) -> CallerType:
"""Where the activity originated from."""
if self.app_id:
return "bot"
return "azure"

@property
def from_id(self) -> str:
"""The id of the activity sender."""
if self.from_ == "bot":
return f"{CallerIds.BOT}:{self.app_id}"
return CallerIds.AZURE

@property
def expiration(self) -> Optional[int]:
"""The expiration of the token since epoch in milliseconds."""
if self._payload.exp:
return self._payload.exp * 1000
return None

def is_expired(self, buffer_ms: int = 5 * 60 * 1000) -> bool:
"""
Check if the token is expired.

Args:
buffer_ms: Buffer time in milliseconds (default 5 minutes).

Returns:
True if the token is expired, False otherwise.
"""
if not self.expiration:
return False
return self.expiration < (time.time() * 1000) + buffer_ms

def __str__(self) -> str:
"""String form of the token."""
return self._value
63 changes: 63 additions & 0 deletions packages/api/src/microsoft/teams/api/auth/token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from typing import Optional, Protocol

from .caller import CallerType


class TokenProtocol(Protocol):
"""Any authorized token."""

@property
def app_id(self) -> str:
"""The app id."""
...

@property
def app_display_name(self) -> Optional[str]:
"""The app display name."""
...

@property
def tenant_id(self) -> Optional[str]:
"""The tenant id."""
...

@property
def service_url(self) -> str:
"""The service url to send responses to."""
...

@property
def from_(self) -> CallerType:
"""Where the activity originated from."""
...

@property
def from_id(self) -> str:
"""The id of the activity sender."""
...

@property
def expiration(self) -> Optional[int]:
"""The expiration of the token since epoch in milliseconds."""
...

def is_expired(self, buffer_ms: int = 5 * 60 * 1000) -> bool:
"""
Check if the token is expired.

Args:
buffer_ms: Buffer time in milliseconds (default 5 minutes).

Returns:
True if the token is expired, False otherwise.
"""
...

def __str__(self) -> str:
"""String form of the token."""
...
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
from typing import Literal, Optional, Union

from microsoft.teams.common.http import Client, ClientOptions
from pydantic import BaseModel

from ...models import Credentials, CustomBaseModel, TokenCredentials
from ...auth import Credentials, TokenCredentials
from ..base_client import BaseClient


class GetBotTokenResponse(CustomBaseModel):
class GetBotTokenResponse(BaseModel):
"""Response model for bot token requests."""

# Note: These fields use snake_case to match TypeScript exactly
Expand Down
3 changes: 0 additions & 3 deletions packages/api/src/microsoft/teams/api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
from .adaptive_card import __all__ as adaptive_card_all
from .attachment import * # noqa: F403
from .attachment import __all__ as attachment_all
from .auth import * # noqa: F403
from .auth import __all__ as auth_all
from .card import * # noqa: F403
from .card import __all__ as card_all
from .channel_data import * # noqa: F403
Expand Down Expand Up @@ -63,6 +61,5 @@
*conversation_all,
*sign_in_all,
*token_all,
*auth_all,
*token_exchange_all,
]
8 changes: 0 additions & 8 deletions packages/api/src/microsoft/teams/api/models/auth/__init__.py

This file was deleted.

2 changes: 1 addition & 1 deletion packages/api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
load_dotenv(env_file)
except ImportError:
pass # python-dotenv not available, use system environment
from microsoft.teams.api.models import (
from microsoft.teams.api import (
Account,
Activity,
ClientCredentials,
Expand Down
Loading