From 476e4b1873b88a1e3f4169e92af042b4cfdad1e4 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Wed, 29 Oct 2025 19:35:54 -0700 Subject: [PATCH] Add support for certificate files --- .../src/microsoft/teams/api/auth/__init__.py | 9 +++++++- .../microsoft/teams/api/auth/credentials.py | 23 ++++++++++++++++++- packages/apps/src/microsoft/teams/apps/app.py | 15 ++++++++++++ .../apps/src/microsoft/teams/apps/options.py | 6 +++++ .../src/microsoft/teams/apps/token_manager.py | 17 ++++++++++++-- stubs/msal/__init__.pyi | 7 +++++- 6 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/api/src/microsoft/teams/api/auth/__init__.py b/packages/api/src/microsoft/teams/api/auth/__init__.py index 227dfa43..7a2b8266 100644 --- a/packages/api/src/microsoft/teams/api/auth/__init__.py +++ b/packages/api/src/microsoft/teams/api/auth/__init__.py @@ -4,13 +4,20 @@ """ from .caller import CallerIds, CallerType -from .credentials import ClientCredentials, Credentials, ManagedIdentityCredentials, TokenCredentials +from .credentials import ( + CertificateCredentials, + ClientCredentials, + Credentials, + ManagedIdentityCredentials, + TokenCredentials, +) from .json_web_token import JsonWebToken, JsonWebTokenPayload from .token import TokenProtocol __all__ = [ "CallerIds", "CallerType", + "CertificateCredentials", "ClientCredentials", "Credentials", "ManagedIdentityCredentials", diff --git a/packages/api/src/microsoft/teams/api/auth/credentials.py b/packages/api/src/microsoft/teams/api/auth/credentials.py index 2fbeec11..0ecca561 100644 --- a/packages/api/src/microsoft/teams/api/auth/credentials.py +++ b/packages/api/src/microsoft/teams/api/auth/credentials.py @@ -43,6 +43,27 @@ class TokenCredentials(CustomBaseModel): """ +class CertificateCredentials(CustomBaseModel): + """Credentials for authentication using X.509 certificate (PEM format).""" + + client_id: str + """ + The client ID. + """ + private_key: str + """ + The private key in PEM format. + """ + thumbprint: str + """ + The SHA-1 thumbprint of the certificate. + """ + tenant_id: Optional[str] = None + """ + The tenant ID. This should only be passed in for single tenant apps. + """ + + class ManagedIdentityCredentials(CustomBaseModel): """Credentials for authentication using Azure Managed Identity.""" @@ -61,4 +82,4 @@ class ManagedIdentityCredentials(CustomBaseModel): # Union type for credentials -Credentials = Union[ClientCredentials, TokenCredentials, ManagedIdentityCredentials] +Credentials = Union[ClientCredentials, TokenCredentials, CertificateCredentials, ManagedIdentityCredentials] diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index 4e3ad60e..8c920452 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -17,6 +17,7 @@ ActivityBase, ActivityParams, ApiClient, + CertificateCredentials, ClientCredentials, ConversationAccount, ConversationReference, @@ -290,6 +291,10 @@ def _init_credentials(self) -> Optional[Credentials]: client_id = self.options.client_id or os.getenv("CLIENT_ID") client_secret = self.options.client_secret or os.getenv("CLIENT_SECRET") tenant_id = self.options.tenant_id or os.getenv("TENANT_ID") + certificate_private_key_path = self.options.certificate_private_key_path or os.getenv( + "CERTIFICATE_PRIVATE_KEY_PATH" + ) + certificate_thumbprint = self.options.certificate_thumbprint or os.getenv("CERTIFICATE_THUMBPRINT") token = self.options.token managed_identity_client_id = self.options.managed_identity_client_id or os.getenv("MANAGED_IDENTITY_CLIENT_ID") managed_identity_type = self.options.managed_identity_type or os.getenv("MANAGED_IDENTITY_TYPE") or "user" @@ -323,6 +328,16 @@ def _init_credentials(self) -> Optional[Credentials]: tenant_id=tenant_id, ) + # - If client_id + certificate : use CertificateCredentials (certificate-based auth) + if client_id and certificate_private_key_path and certificate_thumbprint: + with open(certificate_private_key_path, "r") as f: + private_key = f.read() + return CertificateCredentials( + client_id=client_id, + private_key=private_key, + thumbprint=certificate_thumbprint, + tenant_id=tenant_id, + ) return None @overload diff --git a/packages/apps/src/microsoft/teams/apps/options.py b/packages/apps/src/microsoft/teams/apps/options.py index 6fd46c64..77d3bc46 100644 --- a/packages/apps/src/microsoft/teams/apps/options.py +++ b/packages/apps/src/microsoft/teams/apps/options.py @@ -26,6 +26,10 @@ class AppOptions(TypedDict, total=False): token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" + # Certificate-based authentication + certificate_private_key_path: Optional[str] + certificate_thumbprint: Optional[str] + # Managed identity configuration (used when client_id provided without client_secret or token) managed_identity_client_id: Optional[str] """ @@ -66,6 +70,8 @@ class InternalAppOptions: tenant_id: Optional[str] = None """The tenant ID. Required for single-tenant apps.""" token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] = None + certificate_private_key_path: Optional[str] = None + certificate_thumbprint: Optional[str] = None """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" managed_identity_client_id: Optional[str] = None """The managed identity client ID for user-assigned managed identity. Defaults to client_id if not provided.""" diff --git a/packages/apps/src/microsoft/teams/apps/token_manager.py b/packages/apps/src/microsoft/teams/apps/token_manager.py index d6bac752..dbc65396 100644 --- a/packages/apps/src/microsoft/teams/apps/token_manager.py +++ b/packages/apps/src/microsoft/teams/apps/token_manager.py @@ -15,7 +15,11 @@ JsonWebToken, TokenProtocol, ) -from microsoft.teams.api.auth.credentials import ManagedIdentityCredentials, TokenCredentials +from microsoft.teams.api.auth.credentials import ( + CertificateCredentials, + ManagedIdentityCredentials, + TokenCredentials, +) from microsoft.teams.common import ConsoleLogger from msal import ( # pyright: ignore[reportMissingTypeStubs] ConfidentialClientApplication, @@ -69,7 +73,7 @@ async def _get_token( if caller_name: self._logger.debug(f"No credentials provided for {caller_name}") return None - if isinstance(credentials, (ClientCredentials, ManagedIdentityCredentials)): + if isinstance(credentials, (ClientCredentials, CertificateCredentials, ManagedIdentityCredentials)): tenant_id_param = tenant_id or credentials.tenant_id or "botframework.com" msal_client = self._get_msal_client(tenant_id_param) @@ -123,6 +127,15 @@ def _get_msal_client(self, tenant_id: str) -> ConfidentialClientApplication | Ma client_credential=credentials.client_secret, authority=f"https://login.microsoftonline.com/{tenant_id}", ) + elif isinstance(credentials, CertificateCredentials): + client = ConfidentialClientApplication( + credentials.client_id, + client_credential={ + "private_key": credentials.private_key, + "thumbprint": credentials.thumbprint, + }, + authority=f"https://login.microsoftonline.com/{tenant_id}", + ) elif isinstance(credentials, ManagedIdentityCredentials): # Create the appropriate managed identity based on type if credentials.managed_identity_type == "system": diff --git a/stubs/msal/__init__.pyi b/stubs/msal/__init__.pyi index cefbbc0d..23bf1fc5 100644 --- a/stubs/msal/__init__.pyi +++ b/stubs/msal/__init__.pyi @@ -6,7 +6,12 @@ class ConfidentialClientApplication: """MSAL Confidential Client Application""" def __init__( - self, client_id: str, *, client_credential: Optional[str] = None, authority: Optional[str] = None, **kwargs: Any + self, + client_id: str, + *, + client_credential: Optional[str | dict[str, Any]] = None, + authority: Optional[str] = None, + **kwargs: Any, ) -> None: ... def acquire_token_for_client( self, scopes: list[str], claims_challenge: Optional[str] = None, **kwargs: Any