From 7f2794d5e544f2f80f39e36c77e5f08f86d58515 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Wed, 10 Jun 2026 21:46:00 -0700 Subject: [PATCH 1/8] Start Agent 365 support branch --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 5068afedb..592ca9788 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ A comprehensive SDK for building Microsoft Teams applications, bots, and AI agents using Python. This SDK provides a high-level framework with built-in Microsoft Graph integration, OAuth handling, and extensible plugin architecture. +## Agent 365 Support + +Agent 365 support is being developed on this integration branch. + From ce805eedead2ff3c4789bf83bb8fb2ccdcf67637 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 9 Jun 2026 17:53:19 -0700 Subject: [PATCH 2/8] Add Agent 365 token support --- examples/agent365/README.md | 31 ++++++++ examples/agent365/pyproject.toml | 13 ++++ examples/agent365/src/main.py | 53 +++++++++++++ packages/apps/pyproject.toml | 2 +- .../src/microsoft_teams/apps/token_manager.py | 75 +++++++++++++++++++ stubs/msal/__init__.pyi | 21 +++++- uv.lock | 24 +++++- 7 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 examples/agent365/README.md create mode 100644 examples/agent365/pyproject.toml create mode 100644 examples/agent365/src/main.py diff --git a/examples/agent365/README.md b/examples/agent365/README.md new file mode 100644 index 000000000..c3164c58a --- /dev/null +++ b/examples/agent365/README.md @@ -0,0 +1,31 @@ +# agent365 + +Acquire an Agent 365 agent user token using an agent identity blueprint, an agent identity app ID, and an agent user. + +## Run + +Set these environment variables or add them to `.env`: + +```bash +AGENT365_TENANT_ID= +AGENT365_BLUEPRINT_CLIENT_ID= +AGENT365_BLUEPRINT_CLIENT_SECRET= +AGENT365_AGENT_IDENTITY_APP_ID= +AGENT365_AGENT_USER_ID= +``` + +Then run: + +```bash +uv run --project examples/agent365 python src/main.py +``` + +By default this requests a Teams bot API token for `https://botapi.skype.com/.default`. + +To request another resource, set `AGENT365_SCOPE`, for example: + +```bash +AGENT365_SCOPE=https://graph.microsoft.com/.default +``` + +You can use `AGENT365_AGENT_USER_UPN` instead of `AGENT365_AGENT_USER_ID`. diff --git a/examples/agent365/pyproject.toml b/examples/agent365/pyproject.toml new file mode 100644 index 000000000..43fc1ee3d --- /dev/null +++ b/examples/agent365/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "agent365" +version = "0.1.0" +description = "Agent 365 token example" +readme = "README.md" +requires-python = ">=3.11,<4.0" +dependencies = [ + "dotenv>=0.9.9", + "microsoft-teams-apps", +] + +[tool.uv.sources] +microsoft-teams-apps = { workspace = true } diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py new file mode 100644 index 000000000..98ddca0d2 --- /dev/null +++ b/examples/agent365/src/main.py @@ -0,0 +1,53 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import asyncio +import os + +from dotenv import load_dotenv +from microsoft_teams.api import ClientCredentials +from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TokenManager + + +def get_required_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise ValueError(f"{name} must be set") + + return value + + +async def main(): + load_dotenv() + + tenant_id = get_required_env("AGENT365_TENANT_ID") + blueprint_client_id = get_required_env("AGENT365_BLUEPRINT_CLIENT_ID") + blueprint_client_secret = get_required_env("AGENT365_BLUEPRINT_CLIENT_SECRET") + agent_identity_app_id = get_required_env("AGENT365_AGENT_IDENTITY_APP_ID") + agent_user_id = os.getenv("AGENT365_AGENT_USER_ID") + agent_user_upn = os.getenv("AGENT365_AGENT_USER_UPN") + scope = os.getenv("AGENT365_SCOPE", AGENT_BOT_API_SCOPE) + + credentials = ClientCredentials( + client_id=blueprint_client_id, + client_secret=blueprint_client_secret, + tenant_id=tenant_id, + ) + token_manager = TokenManager(credentials=credentials) + + token = await token_manager.get_agent_user_token( + tenant_id, + agent_identity_app_id, + scope, + agent_user_id=agent_user_id, + agent_user_upn=agent_user_upn, + ) + + print(f"Acquired agent user token for {scope}") + print(f"Token preview: {str(token)[:20]}...") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/packages/apps/pyproject.toml b/packages/apps/pyproject.toml index c27b9db69..6a2b33c7f 100644 --- a/packages/apps/pyproject.toml +++ b/packages/apps/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "cryptography>=3.4.0", "pyjwt[crypto]>=2.12.0", "dependency-injector>=4.48.1", - "msal>=1.33.0", + "msal>=1.37.0", "python-dotenv>=1.0.0", "pydantic-settings>=2.11.0", ] diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index e4c3525f1..cd369ceef 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -29,6 +29,8 @@ ) DEFAULT_TENANT_FOR_GRAPH_TOKEN = "common" +TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default" +AGENT_BOT_API_SCOPE = "https://botapi.skype.com/.default" logger = logging.getLogger(__name__) @@ -68,6 +70,79 @@ async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[Tok self._cloud.graph_scope, tenant_id=self._resolve_tenant_id(tenant_id, DEFAULT_TENANT_FOR_GRAPH_TOKEN) ) + async def get_agent_bot_token( + self, + tenant_id: str, + agent_identity_app_id: str, + *, + agent_user_id: str | None = None, + agent_user_upn: str | None = None, + ) -> Optional[TokenProtocol]: + """Get a Teams bot API token for an agent identity acting through its agent user.""" + return await self.get_agent_user_token( + tenant_id, + agent_identity_app_id, + AGENT_BOT_API_SCOPE, + agent_user_id=agent_user_id, + agent_user_upn=agent_user_upn, + ) + + async def get_agent_user_token( + self, + tenant_id: str, + agent_identity_app_id: str, + scope: str, + *, + agent_user_id: str | None = None, + agent_user_upn: str | None = None, + ) -> Optional[TokenProtocol]: + """Get a resource token for an agent identity acting through its agent user.""" + if not agent_user_id and not agent_user_upn: + raise ValueError("one of agent user id or agent user upn needs to be provided") + if agent_user_id and agent_user_upn: + raise ValueError("agent user id and agent user upn are mutually exclusive") + if self._credentials is None: + logger.debug("No credentials provided for get_agent_user_token") + return None + + credentials = self._credentials + if not isinstance(credentials, ClientCredentials): + raise ValueError("agent tokens require client credentials") + confidential_client = self._get_confidential_client(credentials, tenant_id) + + def get_t1_assertion(_context: dict[str, Any]) -> str: + t1_raw: dict[str, Any] = confidential_client.acquire_token_for_client( + [TOKEN_EXCHANGE_SCOPE], fmi_path=agent_identity_app_id + ) + if t1_raw.get("access_token", None): + return t1_raw["access_token"] + + raise ValueError(f"t1: {t1_raw}") + + t2_confidential_client = ConfidentialClientApplication( + agent_identity_app_id, + client_credential={"client_assertion": get_t1_assertion}, + authority=f"{self._cloud.login_endpoint}/{tenant_id}", + ) + + t2_raw: dict[str, Any] = await asyncio.to_thread( + lambda: t2_confidential_client.acquire_token_for_client([TOKEN_EXCHANGE_SCOPE]) + ) + + if t2_raw.get("access_token", None): + t2 = t2_raw["access_token"] + else: + raise ValueError(f"t2: {t2_raw}") + + t3_raw = t2_confidential_client.acquire_token_by_user_federated_identity_credential( + [scope], + assertion=t2, + user_object_id=agent_user_id, + username=agent_user_upn, + data={"requested_token_use": "on_behalf_of"}, + ) + return self._handle_token_response(t3_raw, "get_agent_token") + async def _get_token( self, scope: str, tenant_id: str, *, caller_name: str | None = None ) -> Optional[TokenProtocol]: diff --git a/stubs/msal/__init__.pyi b/stubs/msal/__init__.pyi index 0ec244167..5e5272ced 100644 --- a/stubs/msal/__init__.pyi +++ b/stubs/msal/__init__.pyi @@ -1,8 +1,8 @@ """Type stubs for msal""" -from typing import Any, Callable, TypeAlias +from typing import Any, Callable, Optional, TypeAlias -ClientCredential: TypeAlias = str | dict[str, str | Callable[[], str]] | None +ClientCredential: TypeAlias = str | dict[str, str | Callable[[], str] | Callable[[dict[str, Any]], str]] | None class ConfidentialClientApplication: """MSAL Confidential Client Application""" @@ -16,7 +16,22 @@ class ConfidentialClientApplication: **kwargs: Any, ) -> None: ... def acquire_token_for_client( - self, scopes: list[str] | str, claims_challenge: str | None = None, **kwargs: Any + self, + scopes: list[str] | str, + claims_challenge: str | None = None, + *, + fmi_path: str | None = None, + **kwargs: Any, + ) -> dict[str, Any]: ... + def acquire_token_by_user_federated_identity_credential( + self, + scopes: list[str], + assertion: str | Callable[[], str], + *, + username: Optional[str] = None, + user_object_id: Optional[str] = None, + claims_challenge: Optional[None] = None, + **kwargs: Any, ) -> dict[str, Any]: ... class SystemAssignedManagedIdentity: diff --git a/uv.lock b/uv.lock index 7a2693d06..44fe0682e 100644 --- a/uv.lock +++ b/uv.lock @@ -9,6 +9,7 @@ resolution-markers = [ [manifest] members = [ "a2a", + "agent365", "ai-agentframework", "botbuilder", "cards", @@ -129,6 +130,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/58/1878b9ac4db703f40c687129c0bccdbf88c94d557b64f0172f3c4e954558/agent_framework_openai-1.1.0-py3-none-any.whl", hash = "sha256:8fbcdb87fbc3fb6aa6f3d781a61a2c48fbc308d2ee8165454f94e53e026a787b", size = 50280, upload-time = "2026-04-21T06:20:11.864Z" }, ] +[[package]] +name = "agent365" +version = "0.1.0" +source = { virtual = "examples/agent365" } +dependencies = [ + { name = "dotenv" }, + { name = "microsoft-teams-apps" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, +] + [[package]] name = "ai-agentframework" version = "0.1.0" @@ -1818,7 +1834,7 @@ requires-dist = [ { name = "microsoft-teams-api", editable = "packages/api" }, { name = "microsoft-teams-common", editable = "packages/common" }, { name = "microsoft-teams-graph", marker = "extra == 'graph'", editable = "packages/graph" }, - { name = "msal", specifier = ">=1.33.0" }, + { name = "msal", specifier = ">=1.37.0" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, @@ -1931,16 +1947,16 @@ dev = [ [[package]] name = "msal" -version = "1.34.0" +version = "1.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyjwt", extra = ["crypto"] }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" }, ] [[package]] From 0c1db39a9e67b21ef5186bc10167f6d94281b4fd Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 9 Jun 2026 18:01:28 -0700 Subject: [PATCH 3/8] Improve Agent 365 token errors --- .../src/microsoft_teams/apps/token_manager.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index cd369ceef..3ac6df4b3 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -98,26 +98,23 @@ async def get_agent_user_token( ) -> Optional[TokenProtocol]: """Get a resource token for an agent identity acting through its agent user.""" if not agent_user_id and not agent_user_upn: - raise ValueError("one of agent user id or agent user upn needs to be provided") + raise ValueError("Either agent_user_id or agent_user_upn must be provided") if agent_user_id and agent_user_upn: - raise ValueError("agent user id and agent user upn are mutually exclusive") + raise ValueError("agent_user_id and agent_user_upn are mutually exclusive") if self._credentials is None: logger.debug("No credentials provided for get_agent_user_token") return None credentials = self._credentials if not isinstance(credentials, ClientCredentials): - raise ValueError("agent tokens require client credentials") + raise ValueError("Agent user tokens require ClientCredentials") confidential_client = self._get_confidential_client(credentials, tenant_id) def get_t1_assertion(_context: dict[str, Any]) -> str: t1_raw: dict[str, Any] = confidential_client.acquire_token_for_client( [TOKEN_EXCHANGE_SCOPE], fmi_path=agent_identity_app_id ) - if t1_raw.get("access_token", None): - return t1_raw["access_token"] - - raise ValueError(f"t1: {t1_raw}") + return self._get_access_token_or_raise(t1_raw, "Agent token exchange step 1 failed") t2_confidential_client = ConfidentialClientApplication( agent_identity_app_id, @@ -129,10 +126,7 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: lambda: t2_confidential_client.acquire_token_for_client([TOKEN_EXCHANGE_SCOPE]) ) - if t2_raw.get("access_token", None): - t2 = t2_raw["access_token"] - else: - raise ValueError(f"t2: {t2_raw}") + t2 = self._get_access_token_or_raise(t2_raw, "Agent token exchange step 2 failed") t3_raw = t2_confidential_client.acquire_token_by_user_federated_identity_credential( [scope], @@ -143,6 +137,15 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: ) return self._handle_token_response(t3_raw, "get_agent_token") + def _get_access_token_or_raise(self, token_res: dict[str, Any], error_prefix: str) -> str: + if token_res.get("access_token", None): + return token_res["access_token"] + + error_description = token_res.get("error_description") or token_res.get("error") or "Could not acquire token" + logger.error(f"{error_prefix}: {error_description}") + logger.debug(f"TokenRes: {token_res}") + raise ValueError(f"{error_prefix}: {error_description}") + async def _get_token( self, scope: str, tenant_id: str, *, caller_name: str | None = None ) -> Optional[TokenProtocol]: From 09122d955d56d4a35ef71678105d7c330165e536 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Wed, 10 Jun 2026 21:11:12 -0700 Subject: [PATCH 4/8] Cache Agent ID MSAL clients --- .../src/microsoft_teams/apps/token_manager.py | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index 3ac6df4b3..693d844de 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -6,7 +6,7 @@ import asyncio import logging from inspect import isawaitable -from typing import Any, Optional +from typing import Any, Callable, Optional import requests from microsoft_teams.api import ( @@ -47,6 +47,7 @@ def __init__( self._cloud = cloud or PUBLIC self._confidential_clients_by_tenant: dict[str, ConfidentialClientApplication] = {} self._federated_identity_clients_by_tenant: dict[str, ConfidentialClientApplication] = {} + self._agent_identity_clients_by_tenant_and_app_id: dict[tuple[str, str], ConfidentialClientApplication] = {} self._managed_identity_client: Optional[ManagedIdentityClient] = None async def get_bot_token(self) -> Optional[TokenProtocol]: @@ -116,10 +117,10 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: ) return self._get_access_token_or_raise(t1_raw, "Agent token exchange step 1 failed") - t2_confidential_client = ConfidentialClientApplication( + t2_confidential_client = self._get_agent_identity_client( + tenant_id, agent_identity_app_id, - client_credential={"client_assertion": get_t1_assertion}, - authority=f"{self._cloud.login_endpoint}/{tenant_id}", + get_t1_assertion, ) t2_raw: dict[str, Any] = await asyncio.to_thread( @@ -298,6 +299,24 @@ def _get_federated_identity_client( self._federated_identity_clients_by_tenant[tenant_id] = client return client + def _get_agent_identity_client( + self, + tenant_id: str, + agent_identity_app_id: str, + client_assertion: Callable[[dict[str, Any]], str], + ) -> ConfidentialClientApplication: + cached_client = self._agent_identity_clients_by_tenant_and_app_id.get((tenant_id, agent_identity_app_id)) + if cached_client: + return cached_client + + client: ConfidentialClientApplication = ConfidentialClientApplication( + agent_identity_app_id, + client_credential={"client_assertion": client_assertion}, + authority=f"{self._cloud.login_endpoint}/{tenant_id}", + ) + self._agent_identity_clients_by_tenant_and_app_id[(tenant_id, agent_identity_app_id)] = client + return client + def _get_managed_identity_client( self, credentials: ManagedIdentityCredentials | FederatedIdentityCredentials ) -> ManagedIdentityClient: From 5ac3399b8c198ad5b56b5bed5cbaad6e4eb81af4 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Thu, 11 Jun 2026 08:35:12 -0700 Subject: [PATCH 5/8] Address Agent 365 token review feedback --- .../apps/src/microsoft_teams/apps/token_manager.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index 693d844de..ca8637e9c 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -78,6 +78,7 @@ async def get_agent_bot_token( *, agent_user_id: str | None = None, agent_user_upn: str | None = None, + caller_name: str | None = None, ) -> Optional[TokenProtocol]: """Get a Teams bot API token for an agent identity acting through its agent user.""" return await self.get_agent_user_token( @@ -86,6 +87,7 @@ async def get_agent_bot_token( AGENT_BOT_API_SCOPE, agent_user_id=agent_user_id, agent_user_upn=agent_user_upn, + caller_name=caller_name, ) async def get_agent_user_token( @@ -96,6 +98,7 @@ async def get_agent_user_token( *, agent_user_id: str | None = None, agent_user_upn: str | None = None, + caller_name: str | None = None, ) -> Optional[TokenProtocol]: """Get a resource token for an agent identity acting through its agent user.""" if not agent_user_id and not agent_user_upn: @@ -103,7 +106,8 @@ async def get_agent_user_token( if agent_user_id and agent_user_upn: raise ValueError("agent_user_id and agent_user_upn are mutually exclusive") if self._credentials is None: - logger.debug("No credentials provided for get_agent_user_token") + if caller_name: + logger.debug(f"No credentials provided for {caller_name}") return None credentials = self._credentials @@ -117,6 +121,8 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: ) return self._get_access_token_or_raise(t1_raw, "Agent token exchange step 1 failed") + # The agent identity app needs its own MSAL client. It uses the Federated Managed + # Identity assertion from step 1 as its client assertion for the next exchanges. t2_confidential_client = self._get_agent_identity_client( tenant_id, agent_identity_app_id, @@ -136,7 +142,7 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: username=agent_user_upn, data={"requested_token_use": "on_behalf_of"}, ) - return self._handle_token_response(t3_raw, "get_agent_token") + return self._handle_token_response(t3_raw, caller_name or "get_agent_user_token") def _get_access_token_or_raise(self, token_res: dict[str, Any], error_prefix: str) -> str: if token_res.get("access_token", None): @@ -144,7 +150,6 @@ def _get_access_token_or_raise(self, token_res: dict[str, Any], error_prefix: st error_description = token_res.get("error_description") or token_res.get("error") or "Could not acquire token" logger.error(f"{error_prefix}: {error_description}") - logger.debug(f"TokenRes: {token_res}") raise ValueError(f"{error_prefix}: {error_description}") async def _get_token( From da0981dbd6cc8677f637805707e8512cf5023398 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Thu, 11 Jun 2026 08:38:17 -0700 Subject: [PATCH 6/8] Avoid blocking on Agent ID user token exchange --- .../apps/src/microsoft_teams/apps/token_manager.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index ca8637e9c..feddfccb8 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -135,12 +135,14 @@ def get_t1_assertion(_context: dict[str, Any]) -> str: t2 = self._get_access_token_or_raise(t2_raw, "Agent token exchange step 2 failed") - t3_raw = t2_confidential_client.acquire_token_by_user_federated_identity_credential( - [scope], - assertion=t2, - user_object_id=agent_user_id, - username=agent_user_upn, - data={"requested_token_use": "on_behalf_of"}, + t3_raw: dict[str, Any] = await asyncio.to_thread( + lambda: t2_confidential_client.acquire_token_by_user_federated_identity_credential( + [scope], + assertion=t2, + user_object_id=agent_user_id, + username=agent_user_upn, + data={"requested_token_use": "on_behalf_of"}, + ) ) return self._handle_token_response(t3_raw, caller_name or "get_agent_user_token") From b41b95cbeebc0368ed980b905ab7764c12f48f55 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Wed, 10 Jun 2026 18:50:51 -0700 Subject: [PATCH 7/8] Support Entra inbound Agent ID tokens --- .../src/microsoft_teams/apps/auth/__init__.py | 4 +- .../apps/auth/token_validator.py | 59 +++++++++++++++++++ .../microsoft_teams/apps/http/http_server.py | 6 +- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/auth/__init__.py b/packages/apps/src/microsoft_teams/apps/auth/__init__.py index f64ff6335..bf7b38ffe 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/auth/__init__.py @@ -4,6 +4,6 @@ """ from .remote_function_jwt_middleware import validate_remote_function_request -from .token_validator import TokenValidator +from .token_validator import InboundActivityTokenValidator, TokenValidator -__all__ = ["TokenValidator", "validate_remote_function_request"] +__all__ = ["InboundActivityTokenValidator", "TokenValidator", "validate_remote_function_request"] 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 a4f1df1d4..8105e2443 100644 --- a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py +++ b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py @@ -34,6 +34,10 @@ class JwtValidationOptions: """ Optional scope that must be present in the token """ clock_tolerance: int = JWT_LEEWAY_SECONDS """ Allowable clock skew when validating JWTs """ + app_id: Optional[str] = None + """ Optional app ID used to select per-token validators for inbound activities """ + cloud: CloudEnvironment = PUBLIC + """ Cloud environment used to select issuer and JWKS endpoints """ class TokenValidator: @@ -83,6 +87,8 @@ def for_service( valid_audiences=cls._default_audiences(app_id), jwks_uri=jwks_keys_uri, service_url=service_url, + app_id=app_id, + cloud=env, ) return cls(options) @@ -128,6 +134,8 @@ def for_entra( valid_audiences=valid_audiences, jwks_uri=f"{env.login_endpoint}/{tenant_id}/discovery/v2.0/keys", scope=scope, + app_id=app_id, + cloud=env, ) return cls(options) @@ -222,3 +230,54 @@ def _validate_scope(self, payload: Dict[str, Any], required_scope: str) -> None: if required_scope not in scope_set: logger.error(f"Token missing required scope: {required_scope}") raise jwt.InvalidTokenError(f"Token missing required scope: {required_scope}") + + +class InboundActivityTokenValidator: + """Validator for inbound Teams activities. + + Classic bot activities use Bot Framework connector tokens. Agent ID activities use + Entra tokens whose audience is the agent identity blueprint app ID. + """ + + def __init__(self, app_id: str, cloud: Optional[CloudEnvironment] = None): + self._app_id = app_id + self._cloud = cloud or PUBLIC + self._service_validator = TokenValidator.for_service(app_id, cloud=self._cloud) + self._entra_validators_by_tenant: dict[str, TokenValidator] = {} + + async def validate_token(self, raw_token: str, service_url: Optional[str] = None) -> Dict[str, Any]: + unverified_payload = jwt.decode(raw_token, options={"verify_signature": False}) + issuer = unverified_payload.get("iss", "") + if self._is_entra_issuer(issuer): + return await self._validate_entra_token(raw_token, unverified_payload) + + return await self._service_validator.validate_token(raw_token, service_url) + + def _is_entra_issuer(self, issuer: Any) -> bool: + if not isinstance(issuer, str): + return False + + return issuer.startswith(self._cloud.login_endpoint) or issuer.startswith("https://sts.windows.net/") + + async def _validate_entra_token(self, raw_token: str, unverified_payload: Dict[str, Any]) -> Dict[str, Any]: + tenant_id = unverified_payload.get("tid") + if not tenant_id or not isinstance(tenant_id, str): + raise jwt.InvalidTokenError("Entra inbound token is missing tid") + + validator = self._get_entra_validator(tenant_id) + # TODO: Agent ID inbound Entra tokens currently do not include serviceurl. Revisit service URL + # validation for this path once the platform defines a signed service URL claim or equivalent. + return await validator.validate_token(raw_token) + + def _get_entra_validator(self, tenant_id: str) -> TokenValidator: + cached_validator = self._entra_validators_by_tenant.get(tenant_id) + if cached_validator: + return cached_validator + + validator = TokenValidator.for_entra( + self._app_id, + tenant_id, + cloud=self._cloud, + ) + self._entra_validators_by_tenant[tenant_id] = validator + return validator 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 e11c411a9..eb7a5129a 100644 --- a/packages/apps/src/microsoft_teams/apps/http/http_server.py +++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py @@ -13,7 +13,7 @@ from microsoft_teams.api.auth.json_web_token import JsonWebToken from pydantic import BaseModel -from ..auth import TokenValidator +from ..auth import InboundActivityTokenValidator from ..events import ActivityEvent, CoreActivity from .adapter import HttpRequest, HttpResponse, HttpServerAdapter @@ -43,7 +43,7 @@ def __init__(self, adapter: HttpServerAdapter, messaging_endpoint: str = "/api/m raise ValueError("messaging_endpoint must be a non-empty path starting with '/'.") self._messaging_endpoint = normalized_endpoint self._on_request: Optional[Callable[[ActivityEvent], Awaitable[InvokeResponse[Any]]]] = None - self._token_validator: Optional[TokenValidator] = None + self._token_validator: Optional[InboundActivityTokenValidator] = None self._skip_auth: bool = False self._cloud: CloudEnvironment = PUBLIC self._initialized: bool = False @@ -89,7 +89,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( + self._token_validator = InboundActivityTokenValidator( app_id, cloud=self._cloud, ) From 0606002b599b20f0ff2d6efe51a22acf36094dab Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Fri, 12 Jun 2026 22:53:29 -0700 Subject: [PATCH 8/8] Add AgenticIdentity API surface alternative --- examples/agent365/README.md | 35 ++--- examples/agent365/src/main.py | 56 +++---- .../api/auth/cloud_environment.py | 6 + .../microsoft_teams/api/auth/credentials.py | 9 +- .../microsoft_teams/api/clients/__init__.py | 2 + .../microsoft_teams/api/clients/api_client.py | 14 +- .../api/clients/base_client.py | 25 +++- .../api/clients/conversation/activity.py | 67 +++++++-- .../api/clients/conversation/client.py | 58 ++++--- .../microsoft_teams/api/models/__init__.py | 2 + .../api/models/agentic_identity.py | 23 +++ .../microsoft_teams/apps/activity_sender.py | 24 ++- packages/apps/src/microsoft_teams/apps/app.py | 141 ++++++++++++++++-- .../src/microsoft_teams/apps/app_process.py | 3 + .../src/microsoft_teams/apps/auth_provider.py | 36 +++++ .../apps/src/microsoft_teams/apps/options.py | 10 +- .../apps/routing/activity_context.py | 29 +++- .../src/microsoft_teams/apps/token_manager.py | 37 ++++- 18 files changed, 448 insertions(+), 129 deletions(-) create mode 100644 packages/api/src/microsoft_teams/api/models/agentic_identity.py create mode 100644 packages/apps/src/microsoft_teams/apps/auth_provider.py diff --git a/examples/agent365/README.md b/examples/agent365/README.md index c3164c58a..cafacf74c 100644 --- a/examples/agent365/README.md +++ b/examples/agent365/README.md @@ -1,31 +1,18 @@ # agent365 -Acquire an Agent 365 agent user token using an agent identity blueprint, an agent identity app ID, and an agent user. +Demonstrates passing `AgenticIdentity` directly to Teams API surfaces. -## Run +## Proactive API Send -Set these environment variables or add them to `.env`: +`src/main.py` sends through the normal API client, but supplies `agentic_identity` to the activity create operation. The API layer uses the agentic token provider to put the right Agent ID token in the request header. ```bash -AGENT365_TENANT_ID= -AGENT365_BLUEPRINT_CLIENT_ID= -AGENT365_BLUEPRINT_CLIENT_SECRET= -AGENT365_AGENT_IDENTITY_APP_ID= -AGENT365_AGENT_USER_ID= +export CLIENT_ID= +export CLIENT_SECRET= +export TENANT_ID= + +uv run --project examples/agent365 python src/main.py \ + \ + \ + ``` - -Then run: - -```bash -uv run --project examples/agent365 python src/main.py -``` - -By default this requests a Teams bot API token for `https://botapi.skype.com/.default`. - -To request another resource, set `AGENT365_SCOPE`, for example: - -```bash -AGENT365_SCOPE=https://graph.microsoft.com/.default -``` - -You can use `AGENT365_AGENT_USER_UPN` instead of `AGENT365_AGENT_USER_ID`. diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py index 98ddca0d2..f9622093d 100644 --- a/examples/agent365/src/main.py +++ b/examples/agent365/src/main.py @@ -3,50 +3,36 @@ Licensed under the MIT License. """ +import argparse import asyncio -import os +import logging -from dotenv import load_dotenv -from microsoft_teams.api import ClientCredentials -from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TokenManager +from microsoft_teams.api import MessageActivityInput +from microsoft_teams.apps import App +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) -def get_required_env(name: str) -> str: - value = os.getenv(name) - if not value: - raise ValueError(f"{name} must be set") - return value +async def main(): + parser = argparse.ArgumentParser(description="Send a proactive message using API-level AgenticIdentity") + parser.add_argument("conversation_id", help="The Teams conversation ID to send messages to") + parser.add_argument("agentic_app_id", help="The concrete agent identity app/client ID") + parser.add_argument("agentic_user_id", help="The agent user object ID") + args = parser.parse_args() + app = App() + await app.initialize() -async def main(): - load_dotenv() - - tenant_id = get_required_env("AGENT365_TENANT_ID") - blueprint_client_id = get_required_env("AGENT365_BLUEPRINT_CLIENT_ID") - blueprint_client_secret = get_required_env("AGENT365_BLUEPRINT_CLIENT_SECRET") - agent_identity_app_id = get_required_env("AGENT365_AGENT_IDENTITY_APP_ID") - agent_user_id = os.getenv("AGENT365_AGENT_USER_ID") - agent_user_upn = os.getenv("AGENT365_AGENT_USER_UPN") - scope = os.getenv("AGENT365_SCOPE", AGENT_BOT_API_SCOPE) - - credentials = ClientCredentials( - client_id=blueprint_client_id, - client_secret=blueprint_client_secret, - tenant_id=tenant_id, - ) - token_manager = TokenManager(credentials=credentials) - - token = await token_manager.get_agent_user_token( - tenant_id, - agent_identity_app_id, - scope, - agent_user_id=agent_user_id, - agent_user_upn=agent_user_upn, + agentic_identity = app.get_agentic_identity(args.agentic_app_id, args.agentic_user_id) + activity = MessageActivityInput(text="Hello from an API-level AgenticIdentity.") + + result = await app.api.conversations.activities(args.conversation_id).create( + activity, + agentic_identity=agentic_identity, ) - print(f"Acquired agent user token for {scope}") - print(f"Token preview: {str(token)[:20]}...") + logger.info("Sent activity as agentic identity. Activity ID: %s", result.id) if __name__ == "__main__": diff --git a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py index 4821ca5b8..813458bf0 100644 --- a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py +++ b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py @@ -21,6 +21,8 @@ class CloudEnvironment: """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").""" + agentic_bot_scope: str + """The Teams Bot API scope for Agent ID user-token calls.""" token_service_url: str """The Bot Framework token service base URL (e.g. "https://token.botframework.com").""" openid_metadata_url: str @@ -35,6 +37,7 @@ class CloudEnvironment: login_endpoint="https://login.microsoftonline.com", login_tenant="botframework.com", bot_scope="https://api.botframework.com/.default", + agentic_bot_scope="https://botapi.skype.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", @@ -46,6 +49,7 @@ class CloudEnvironment: login_endpoint="https://login.microsoftonline.us", login_tenant="MicrosoftServices.onmicrosoft.us", bot_scope="https://api.botframework.us/.default", + agentic_bot_scope="https://botapi.skype.com/.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", @@ -57,6 +61,7 @@ class CloudEnvironment: login_endpoint="https://login.microsoftonline.us", login_tenant="MicrosoftServices.onmicrosoft.us", bot_scope="https://api.botframework.us/.default", + agentic_bot_scope="https://botapi.skype.com/.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", @@ -68,6 +73,7 @@ class CloudEnvironment: login_endpoint="https://login.partner.microsoftonline.cn", login_tenant="microsoftservices.partner.onmschina.cn", bot_scope="https://api.botframework.azure.cn/.default", + agentic_bot_scope="https://botapi.skype.com/.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", diff --git a/packages/api/src/microsoft_teams/api/auth/credentials.py b/packages/api/src/microsoft_teams/api/auth/credentials.py index 471c7952c..ba59d359b 100644 --- a/packages/api/src/microsoft_teams/api/auth/credentials.py +++ b/packages/api/src/microsoft_teams/api/auth/credentials.py @@ -5,7 +5,7 @@ from typing import Awaitable, Callable, Literal, Optional, Union -from ..models import CustomBaseModel +from ..models import AgenticIdentity, CustomBaseModel class ClientCredentials(CustomBaseModel): @@ -36,8 +36,11 @@ class TokenCredentials(CustomBaseModel): """ The tenant ID. """ - # (scope: string | string[], tenantId?: string) => string | Promise - token: Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]] + # (scope: string | string[], tenantId?: string, agenticIdentity?: AgenticIdentity) => string | Promise + token: Callable[ + [Union[str, list[str]], Optional[str], Optional[AgenticIdentity]], + Union[str, Awaitable[str]], + ] """ The token function. """ diff --git a/packages/api/src/microsoft_teams/api/clients/__init__.py b/packages/api/src/microsoft_teams/api/clients/__init__.py index 683d9c1ed..1c7e48ed6 100644 --- a/packages/api/src/microsoft_teams/api/clients/__init__.py +++ b/packages/api/src/microsoft_teams/api/clients/__init__.py @@ -6,6 +6,7 @@ from . import bot, conversation, meeting, reaction, team, user from .api_client import ApiClient from .api_client_settings import ApiClientSettings, merge_api_client_settings +from .base_client import AuthProvider from .bot import * # noqa: F403 from .conversation import * # noqa: F403 from .meeting import * # noqa: F403 @@ -17,6 +18,7 @@ __all__: list[str] = [ "ApiClient", "ApiClientSettings", + "AuthProvider", "merge_api_client_settings", ] __all__.extend(bot.__all__) diff --git a/packages/api/src/microsoft_teams/api/clients/api_client.py b/packages/api/src/microsoft_teams/api/clients/api_client.py index 2da68c7d5..ea9292537 100644 --- a/packages/api/src/microsoft_teams/api/clients/api_client.py +++ b/packages/api/src/microsoft_teams/api/clients/api_client.py @@ -11,7 +11,7 @@ from microsoft_teams.common import ClientOptions from .api_client_settings import ApiClientSettings -from .base_client import BaseClient +from .base_client import AuthProvider, BaseClient from .bot import BotClient from .conversation import ConversationClient from .meeting import MeetingClient @@ -32,6 +32,8 @@ def __init__( options: Optional[Union[HttpClient, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, cloud: Optional[CloudEnvironment] = None, + *, + auth_provider: Optional[AuthProvider] = None, ) -> None: """Initialize the unified Teams API client. @@ -41,13 +43,19 @@ def __init__( api_client_settings: Optional API client settings. cloud: Optional cloud environment for sovereign cloud support. """ - super().__init__(options, api_client_settings) + super().__init__(options, api_client_settings, auth_provider=auth_provider, cloud=cloud) self.service_url = service_url.rstrip("/") # Initialize all client types self.bots = BotClient(self._http, self._api_client_settings, cloud=cloud) self.users = UserClient(self._http, self._api_client_settings) - self.conversations = ConversationClient(self.service_url, self._http, self._api_client_settings) + self.conversations = ConversationClient( + self.service_url, + self._http, + self._api_client_settings, + auth_provider=auth_provider, + cloud=cloud, + ) self.teams = TeamClient(self.service_url, self._http, self._api_client_settings) self.meetings = MeetingClient(self.service_url, self._http, self._api_client_settings) self._reactions: Optional[ReactionClient] = None diff --git a/packages/api/src/microsoft_teams/api/clients/base_client.py b/packages/api/src/microsoft_teams/api/clients/base_client.py index bc5ccd8f2..363ada03b 100644 --- a/packages/api/src/microsoft_teams/api/clients/base_client.py +++ b/packages/api/src/microsoft_teams/api/clients/base_client.py @@ -3,13 +3,22 @@ Licensed under the MIT License. """ -from typing import Optional, Union +from typing import Awaitable, Optional, Protocol, Union +from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment from microsoft_teams.common.http import Client, ClientOptions +from microsoft_teams.common.http.client_token import StringLike +from ..models.agentic_identity import AgenticIdentity from .api_client_settings import ApiClientSettings, merge_api_client_settings +class AuthProvider(Protocol): + def token( + self, scope: str, tenant_id: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> str | StringLike | None | Awaitable[str | StringLike | None]: ... + + class BaseClient: """Base client""" @@ -17,6 +26,9 @@ def __init__( self, options: Optional[Union[Client, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, + *, + auth_provider: Optional[AuthProvider] = None, + cloud: Optional[CloudEnvironment] = None, ) -> None: """Initialize the BaseClient. @@ -32,6 +44,8 @@ def __init__( self._http = Client(options) self._api_client_settings = merge_api_client_settings(api_client_settings) + self._auth_provider = auth_provider + self._cloud = cloud or PUBLIC @property def http(self) -> Client: @@ -42,3 +56,12 @@ def http(self) -> Client: def http(self, value: Client) -> None: """Set the HTTP client instance.""" self._http = value + + def _get_agentic_token(self, identity: AgenticIdentity | None): + if identity is None: + return None + if self._auth_provider is None: + raise ValueError("agentic_identity requires a Teams auth provider") + auth_provider = self._auth_provider + + return lambda: auth_provider.token(self._cloud.agentic_bot_scope, identity.tenant_id, identity) diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py index a1ee86ef8..aa1184b08 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -3,15 +3,18 @@ Licensed under the MIT License. """ -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional from microsoft_teams.common.experimental import experimental from microsoft_teams.common.http import Client from ...activities import ActivityParams, SentActivity -from ...models import TeamsChannelAccount +from ...models import AgenticIdentity, TeamsChannelAccount from ..api_client_settings import ApiClientSettings -from ..base_client import BaseClient +from ..base_client import AuthProvider, BaseClient + +if TYPE_CHECKING: + from ...auth.cloud_environment import CloudEnvironment _PLACEHOLDER_ACTIVITY_ID = "DO_NOT_USE_PLACEHOLDER_ID" @@ -26,6 +29,9 @@ def __init__( service_url: str, http_client: Optional[Client] = None, api_client_settings: Optional[ApiClientSettings] = None, + *, + auth_provider: Optional[AuthProvider] = None, + cloud: Optional["CloudEnvironment"] = None, ): """ Initialize the conversation activity client. @@ -35,10 +41,12 @@ def __init__( http_client: Optional HTTP client to use. If not provided, a new one will be created. api_client_settings: Optional API client settings. """ - super().__init__(http_client, api_client_settings) + super().__init__(http_client, api_client_settings, auth_provider=auth_provider, cloud=cloud) self.service_url = service_url.rstrip("/") - async def create(self, conversation_id: str, activity: ActivityParams) -> SentActivity: + async def create( + self, conversation_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None + ) -> SentActivity: """ Create a new activity in a conversation. @@ -53,6 +61,7 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc response = await self.http.post( f"{self.service_url}/v3/conversations/{conversation_id}/activities", json=activity.model_dump(by_alias=True, exclude_none=True), + token=self._get_agentic_token(agentic_identity), ) # Note: Typing activities (non-streaming) always produce empty responses. @@ -61,7 +70,13 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID) return SentActivity(id=id, activity_params=activity) - async def update(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: + async def update( + self, + conversation_id: str, + activity_id: str, + activity: ActivityParams, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Update an existing activity in a conversation. @@ -76,11 +91,18 @@ async def update(self, conversation_id: str, activity_id: str, activity: Activit response = await self.http.put( f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}", json=activity.model_dump(by_alias=True, exclude_none=True), + token=self._get_agentic_token(agentic_identity), ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) - async def reply(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: + async def reply( + self, + conversation_id: str, + activity_id: str, + activity: ActivityParams, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Reply to an activity in a conversation. @@ -97,11 +119,14 @@ async def reply(self, conversation_id: str, activity_id: str, activity: Activity response = await self.http.post( f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}", json=activity_json, + token=self._get_agentic_token(agentic_identity), ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) - async def delete(self, conversation_id: str, activity_id: str) -> None: + async def delete( + self, conversation_id: str, activity_id: str, agentic_identity: AgenticIdentity | None = None + ) -> None: """ Delete an activity from a conversation. @@ -109,7 +134,10 @@ async def delete(self, conversation_id: str, activity_id: str) -> None: conversation_id: The ID of the conversation activity_id: The ID of the activity to delete """ - await self.http.delete(f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}") + await self.http.delete( + f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}", + token=self._get_agentic_token(agentic_identity), + ) async def get_members(self, conversation_id: str, activity_id: str) -> List[TeamsChannelAccount]: """ @@ -128,7 +156,9 @@ async def get_members(self, conversation_id: str, activity_id: str) -> List[Team return [TeamsChannelAccount.model_validate(member) for member in response.json()] @experimental("ExperimentalTeamsTargeted") - async def create_targeted(self, conversation_id: str, activity: ActivityParams) -> SentActivity: + async def create_targeted( + self, conversation_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None + ) -> SentActivity: """ Create a new targeted activity in a conversation. @@ -148,12 +178,19 @@ async def create_targeted(self, conversation_id: str, activity: ActivityParams) response = await self.http.post( f"{self.service_url}/v3/conversations/{conversation_id}/activities?isTargetedActivity=true", json=activity.model_dump(by_alias=True, exclude_none=True), + token=self._get_agentic_token(agentic_identity), ) id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID) return SentActivity(id=id, activity_params=activity) @experimental("ExperimentalTeamsTargeted") - async def update_targeted(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity: + async def update_targeted( + self, + conversation_id: str, + activity_id: str, + activity: ActivityParams, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Update an existing targeted activity in a conversation. @@ -172,12 +209,15 @@ async def update_targeted(self, conversation_id: str, activity_id: str, activity response = await self.http.put( f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true", json=activity.model_dump(by_alias=True, exclude_none=True), + token=self._get_agentic_token(agentic_identity), ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) @experimental("ExperimentalTeamsTargeted") - async def delete_targeted(self, conversation_id: str, activity_id: str) -> None: + async def delete_targeted( + self, conversation_id: str, activity_id: str, agentic_identity: AgenticIdentity | None = None + ) -> None: """ Delete a targeted activity from a conversation. @@ -190,5 +230,6 @@ async def delete_targeted(self, conversation_id: str, activity_id: str) -> None: activity_id: The ID of the activity to delete """ await self.http.delete( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true" + f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true", + token=self._get_agentic_token(agentic_identity), ) diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/client.py b/packages/api/src/microsoft_teams/api/clients/conversation/client.py index cb9e491ef..14d24ee67 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -3,17 +3,20 @@ Licensed under the MIT License. """ -from typing import Optional, Union +from typing import TYPE_CHECKING, Optional, Union from microsoft_teams.common.http import Client, ClientOptions -from ...models import ConversationResource +from ...models import AgenticIdentity, ConversationResource from ..api_client_settings import ApiClientSettings -from ..base_client import BaseClient +from ..base_client import AuthProvider, BaseClient from .activity import ActivityParams, ConversationActivityClient from .member import ConversationMemberClient from .params import CreateConversationParams +if TYPE_CHECKING: + from ...auth.cloud_environment import CloudEnvironment + class ConversationOperations: """Base class for conversation operations.""" @@ -26,32 +29,40 @@ def __init__(self, client: "ConversationClient", conversation_id: str) -> None: class ActivityOperations(ConversationOperations): """Operations for managing activities in a conversation.""" - async def create(self, activity: ActivityParams): - return await self._client.activities_client.create(self._conversation_id, activity) + async def create(self, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None): + return await self._client.activities_client.create(self._conversation_id, activity, agentic_identity) - async def update(self, activity_id: str, activity: ActivityParams): - return await self._client.activities_client.update(self._conversation_id, activity_id, activity) + async def update(self, activity_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None): + return await self._client.activities_client.update( + self._conversation_id, activity_id, activity, agentic_identity + ) - async def reply(self, activity_id: str, activity: ActivityParams): - return await self._client.activities_client.reply(self._conversation_id, activity_id, activity) + async def reply(self, activity_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None): + return await self._client.activities_client.reply( + self._conversation_id, activity_id, activity, agentic_identity + ) - async def delete(self, activity_id: str): - await self._client.activities_client.delete(self._conversation_id, activity_id) + async def delete(self, activity_id: str, agentic_identity: AgenticIdentity | None = None): + await self._client.activities_client.delete(self._conversation_id, activity_id, agentic_identity) async def get_members(self, activity_id: str): return await self._client.activities_client.get_members(self._conversation_id, activity_id) - async def create_targeted(self, activity: ActivityParams): + async def create_targeted(self, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None): """Create a new targeted activity visible only to the specified recipient.""" - return await self._client.activities_client.create_targeted(self._conversation_id, activity) + return await self._client.activities_client.create_targeted(self._conversation_id, activity, agentic_identity) - async def update_targeted(self, activity_id: str, activity: ActivityParams): + async def update_targeted( + self, activity_id: str, activity: ActivityParams, agentic_identity: AgenticIdentity | None = None + ): """Update an existing targeted activity.""" - return await self._client.activities_client.update_targeted(self._conversation_id, activity_id, activity) + return await self._client.activities_client.update_targeted( + self._conversation_id, activity_id, activity, agentic_identity + ) - async def delete_targeted(self, activity_id: str): + async def delete_targeted(self, activity_id: str, agentic_identity: AgenticIdentity | None = None): """Delete a targeted activity.""" - await self._client.activities_client.delete_targeted(self._conversation_id, activity_id) + await self._client.activities_client.delete_targeted(self._conversation_id, activity_id, agentic_identity) class MemberOperations(ConversationOperations): @@ -75,6 +86,9 @@ def __init__( service_url: str, options: Optional[Union[Client, ClientOptions]] = None, api_client_settings: Optional[ApiClientSettings] = None, + *, + auth_provider: Optional[AuthProvider] = None, + cloud: Optional["CloudEnvironment"] = None, ) -> None: """Initialize the client. @@ -83,10 +97,16 @@ def __init__( options: Either an HTTP client instance or client options. If None, a default client is created. api_client_settings: Optional API client settings. """ - super().__init__(options, api_client_settings) + super().__init__(options, api_client_settings, auth_provider=auth_provider, cloud=cloud) self.service_url = service_url.rstrip("/") - self._activities_client = ConversationActivityClient(self.service_url, self.http, self._api_client_settings) + self._activities_client = ConversationActivityClient( + self.service_url, + self.http, + self._api_client_settings, + auth_provider=auth_provider, + cloud=cloud, + ) self._members_client = ConversationMemberClient(self.service_url, self.http, self._api_client_settings) @property diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py index df7e229fd..b00644124 100644 --- a/packages/api/src/microsoft_teams/api/models/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/__init__.py @@ -27,6 +27,7 @@ from .activity import Activity as ActivityBase from .activity import ActivityInput as ActivityInputBase from .adaptive_card import * # noqa: F403 +from .agentic_identity import AgenticIdentity from .app_based_link_query import AppBasedLinkQuery from .attachment import * # noqa: F403 from .cache_info import CacheInfo @@ -75,6 +76,7 @@ "Action", "ActivityBase", "ActivityInputBase", + "AgenticIdentity", "AppBasedLinkQuery", "CacheInfo", "ChannelID", diff --git a/packages/api/src/microsoft_teams/api/models/agentic_identity.py b/packages/api/src/microsoft_teams/api/models/agentic_identity.py new file mode 100644 index 000000000..213d71b17 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/agentic_identity.py @@ -0,0 +1,23 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AgenticIdentity: + """Identifies an Agent ID user-shaped identity and its backing agent app.""" + + agentic_app_id: str + agentic_user_id: str + tenant_id: str | None = None + agentic_app_blueprint_id: str | None = None + + @property + def channel_account_id(self) -> str: + return self.agentic_user_id if self.agentic_user_id.startswith("8:") else f"8:orgid:{self.agentic_user_id}" + + +__all__ = ["AgenticIdentity"] diff --git a/packages/apps/src/microsoft_teams/apps/activity_sender.py b/packages/apps/src/microsoft_teams/apps/activity_sender.py index 1ab97c96c..9aad3c87e 100644 --- a/packages/apps/src/microsoft_teams/apps/activity_sender.py +++ b/packages/apps/src/microsoft_teams/apps/activity_sender.py @@ -8,11 +8,13 @@ from microsoft_teams.api import ( ActivityParams, + AgenticIdentity, ApiClient, ConversationReference, MessageActivityInput, SentActivity, ) +from microsoft_teams.api.clients.base_client import AuthProvider from microsoft_teams.common import Client from .http_stream import HttpStream @@ -27,7 +29,7 @@ class ActivitySender: Separate from transport concerns (HTTP, WebSocket, etc.) """ - def __init__(self, client: Client): + def __init__(self, client: Client, auth_provider: AuthProvider | None = None): """ Initialize ActivitySender. @@ -35,8 +37,14 @@ def __init__(self, client: Client): client: HTTP client with token provider configured """ self._client = client - - async def send(self, activity: ActivityParams, ref: ConversationReference) -> SentActivity: + self._auth_provider = auth_provider + + async def send( + self, + activity: ActivityParams, + ref: ConversationReference, + agentic_identity: AgenticIdentity | None = None, + ) -> SentActivity: """ Send an activity to the Bot Framework. @@ -57,7 +65,7 @@ async def send(self, activity: ActivityParams, ref: ConversationReference) -> Se raise ValueError("Targeted messages are not supported in 1:1 (personal) chats.") # Create API client for this conversation's service URL - api = ApiClient(service_url=ref.service_url, options=self._client) + api = ApiClient(service_url=ref.service_url, options=self._client, auth_provider=self._auth_provider) # Merge activity with conversation reference activity.from_ = ref.bot @@ -69,15 +77,15 @@ async def send(self, activity: ActivityParams, ref: ConversationReference) -> Se if is_update: activity_id = cast(str, activity.id) if is_targeted: - res = await activities.update_targeted(activity_id, activity) + res = await activities.update_targeted(activity_id, activity, agentic_identity) else: - res = await activities.update(activity_id, activity) + res = await activities.update(activity_id, activity, agentic_identity) return SentActivity.merge(activity, res) if is_targeted: - res = await activities.create_targeted(activity) + res = await activities.create_targeted(activity, agentic_identity) else: - res = await activities.create(activity) + res = await activities.create(activity, agentic_identity) return SentActivity.merge(activity, res) def create_stream(self, ref: ConversationReference) -> StreamerProtocol: diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index c8e93be73..95c0dd39c 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -15,6 +15,7 @@ Account, ActivityBase, ActivityParams, + AgenticIdentity, ApiClient, ClientCredentials, ConversationAccount, @@ -42,6 +43,7 @@ from .app_process import ActivityProcessor from .auth import TokenValidator from .auth.remote_function_jwt_middleware import validate_remote_function_request +from .auth_provider import AppAuthProvider from .container import Container from .contexts.function_context import FunctionContext from .events import ( @@ -100,6 +102,7 @@ def __init__(self, **options: Unpack[AppOptions]): credentials=self.credentials, cloud=self.cloud, ) + self._auth_provider = AppAuthProvider(self._token_manager) self.container = Container() self.container.set_provider("storage", providers.Object(self.storage)) @@ -113,6 +116,7 @@ def __init__(self, **options: Unpack[AppOptions]): self.http_client.clone(ClientOptions(token=self._get_bot_token)), self.options.api_client_settings, cloud=self.cloud, + auth_provider=self._auth_provider, ) plugins: List[PluginBase] = list(self.options.plugins) @@ -126,7 +130,10 @@ def __init__(self, **options: Unpack[AppOptions]): self._initialized = False # initialize ActivitySender for sending activities - self.activity_sender = ActivitySender(self.http_client.clone(ClientOptions(token=self._get_bot_token))) + self.activity_sender = ActivitySender( + self.http_client.clone(ClientOptions(token=self._get_bot_token)), + auth_provider=self._auth_provider, + ) # initialize all event, activity, and plugin processors self.activity_processor = ActivityProcessor( @@ -290,7 +297,27 @@ async def stop(self) -> None: self._events.emit("error", ErrorEvent(error, context={"method": "stop"})) raise - async def send(self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard): + @overload + async def send( + self, + conversation_id: str, + activity: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: ... + + @overload + async def send( + self, + conversation_id: str, + from_: AgenticIdentity, + activity: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: ... + + async def send( # type: ignore[reportInconsistentOverload] + self, + conversation_id: str, + from_or_activity: AgenticIdentity | str | ActivityParams | AdaptiveCard, + activity: str | ActivityParams | AdaptiveCard | None = None, + ) -> SentActivity: """Send an activity proactively to a conversation. Sends to the exact conversation ID provided. For channel threads, @@ -301,24 +328,62 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap if not self._initialized: raise ValueError("app not initialized - call app.initialize() or app.start() first") - if self.id is None: - raise ValueError("app credentials not configured") + if isinstance(from_or_activity, AgenticIdentity): + from_ = from_or_activity + activity_params = activity + if activity_params is None: + raise TypeError("activity is required when sending with an agentic identity") + else: + from_ = None + activity_params = from_or_activity conversation_ref = ConversationReference( channel_id="msteams", service_url=self.api.service_url, - bot=Account(id=self.id), + bot=self._get_outbound_account(from_), conversation=ConversationAccount(id=conversation_id), ) - if isinstance(activity, str): - activity = MessageActivityInput(text=activity) - elif isinstance(activity, AdaptiveCard): - activity = MessageActivityInput().add_card(activity) + if isinstance(activity_params, str): + activity_params = MessageActivityInput(text=activity_params) + elif isinstance(activity_params, AdaptiveCard): + activity_params = MessageActivityInput().add_card(activity_params) else: - activity = activity + activity_params = activity_params + + return await self.activity_sender.send(activity_params, conversation_ref, from_) + + def get_agentic_identity( + self, + agentic_app_id: str, + agentic_user_id: str, + *, + tenant_id: Optional[str] = None, + agentic_app_blueprint_id: Optional[str] = None, + ) -> AgenticIdentity: + """Get an Agent ID identity for API calls.""" + resolved_tenant_id = tenant_id or (self.credentials.tenant_id if self.credentials else None) + if resolved_tenant_id is None: + raise ValueError("tenant_id is required to get an agentic identity") + + return AgenticIdentity( + agentic_app_id=agentic_app_id, + agentic_user_id=agentic_user_id, + tenant_id=resolved_tenant_id, + agentic_app_blueprint_id=agentic_app_blueprint_id, + ) + + def get_scoped_graph(self, identity: AgenticIdentity) -> "GraphServiceClient": + """Get a Graph client scoped to an Agent ID identity.""" + return create_graph_client(lambda: self._get_agentic_graph_token(identity), cloud=self.cloud) - return await self.activity_sender.send(activity, conversation_ref) + def _get_outbound_account(self, from_: AgenticIdentity | None = None) -> Account: + if from_ is None: + if self.id is None: + raise ValueError("app credentials not configured") + return Account(id=self.id) + + return Account(id=from_.channel_account_id) @overload async def reply( @@ -335,10 +400,28 @@ async def reply( message_id: str | ActivityParams | AdaptiveCard, ) -> SentActivity: ... + @overload + async def reply( + self, + conversation_id: str, + from_: AgenticIdentity, + message_id: str, + activity: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: ... + + @overload + async def reply( + self, + conversation_id: str, + from_: AgenticIdentity, + message_id: str | ActivityParams | AdaptiveCard, + ) -> SentActivity: ... + async def reply( # type: ignore[reportInconsistentOverload] self, conversation_id: str, - message_id: str | ActivityParams | AdaptiveCard = "", + from_or_message_id: AgenticIdentity | str | ActivityParams | AdaptiveCard = "", + message_id_or_activity: str | ActivityParams | AdaptiveCard | None = None, activity: str | ActivityParams | AdaptiveCard | None = None, ) -> SentActivity: """Send an activity proactively to a conversation, optionally as a threaded reply. @@ -357,12 +440,26 @@ async def reply( # type: ignore[reportInconsistentOverload] message_id: The thread root message ID (3-arg form) or the activity (2-arg form) activity: The activity to send (only in 3-arg form) """ - if activity is not None: - if not isinstance(message_id, str): + if isinstance(from_or_message_id, AgenticIdentity): + if activity is not None: + if not isinstance(message_id_or_activity, str): + raise TypeError("message_id must be a string when activity is provided") + return await self.send( + to_threaded_conversation_id(conversation_id, message_id_or_activity), from_or_message_id, activity + ) + + if message_id_or_activity is None: + raise TypeError("activity is required when replying with an agentic identity") + return await self.send(conversation_id, from_or_message_id, message_id_or_activity) + + if message_id_or_activity is not None: + if not isinstance(from_or_message_id, str): raise TypeError("message_id must be a string when activity is provided") - return await self.send(to_threaded_conversation_id(conversation_id, message_id), activity) + return await self.send( + to_threaded_conversation_id(conversation_id, from_or_message_id), message_id_or_activity + ) - return await self.send(conversation_id, message_id) + return await self.send(conversation_id, from_or_message_id) def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[None]]) -> None: """Add middleware to run on all activities.""" @@ -596,6 +693,18 @@ async def _stop_plugins(self) -> None: async def _get_bot_token(self): return await self._token_manager.get_bot_token() + async def _get_agentic_graph_token(self, identity: AgenticIdentity) -> Optional[TokenProtocol]: + if identity.tenant_id is None: + raise ValueError("tenant_id is required to get an agentic token") + + return await self._token_manager.get_agent_user_token( + identity.tenant_id, + identity.agentic_app_id, + self.cloud.graph_scope, + agent_user_id=identity.agentic_user_id, + caller_name="get_agentic_graph_token", + ) + async def _get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]: return await self._token_manager.get_graph_token(tenant_id) diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index 1b2e9f27a..bcdd959ce 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -27,6 +27,7 @@ from .app_events import EventManager from .activity_sender import ActivitySender +from .auth_provider import AppAuthProvider from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent, ErrorEvent from .plugins import PluginActivityEvent, PluginBase, StreamCancelledError from .routing.activity_context import ActivityContext @@ -58,6 +59,7 @@ def __init__( self.default_connection_name = default_connection_name self.http_client = http_client self.token_manager = token_manager + self.auth_provider = AppAuthProvider(token_manager) self.api_client_settings = api_client_settings self.activity_sender = activity_sender self.cloud = cloud @@ -95,6 +97,7 @@ async def _build_context( service_url, self.http_client.clone(ClientOptions(token=self.token_manager.get_bot_token)), self.api_client_settings, + auth_provider=self.auth_provider, ) # Check if user is signed in diff --git a/packages/apps/src/microsoft_teams/apps/auth_provider.py b/packages/apps/src/microsoft_teams/apps/auth_provider.py new file mode 100644 index 000000000..d5590ec44 --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/auth_provider.py @@ -0,0 +1,36 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Optional + +from microsoft_teams.api import AgenticIdentity, TokenProtocol + +from .token_manager import TokenManager + + +class AppAuthProvider: + """Provides app and agentic tokens for Teams API clients.""" + + def __init__(self, token_manager: TokenManager): + self._token_manager = token_manager + + async def token( + self, scope: str, tenant_id: str | None = None, agentic_identity: AgenticIdentity | None = None + ) -> Optional[TokenProtocol]: + if agentic_identity is None: + return await self._token_manager.get_bot_token() + if tenant_id is None: + raise ValueError("tenant_id is required to get an agentic token") + + return await self._token_manager.get_agent_user_token( + tenant_id, + agentic_identity.agentic_app_id, + scope, + agent_user_id=agentic_identity.agentic_user_id, + caller_name="token", + ) + + +__all__ = ["AppAuthProvider"] diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index c9d7a363f..c3ca686b2 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union, cast -from microsoft_teams.api import ApiClientSettings +from microsoft_teams.api import AgenticIdentity, ApiClientSettings from microsoft_teams.api.auth.cloud_environment import CloudEnvironment from microsoft_teams.common import Client, ClientOptions, Storage from typing_extensions import Unpack @@ -30,7 +30,9 @@ class AppOptions(TypedDict, total=False): """Application ID URI from the Azure portal. Used for user authentication. Matches webApplicationInfo.resource in the app manifest.""" # Custom token provider function - token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] + token: Optional[ + Callable[[Union[str, list[str]], Optional[str], Optional[AgenticIdentity]], Union[str, Awaitable[str]]] + ] """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" # Managed identity configuration (used when client_id provided without client_secret or token) @@ -111,7 +113,9 @@ class InternalAppOptions: application_id_uri: Optional[str] = None """Application ID URI from the Azure portal. Used for user authentication. Matches webApplicationInfo.resource in the app manifest.""" - token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] = None + token: Optional[ + Callable[[Union[str, list[str]], Optional[str], Optional[AgenticIdentity]], Union[str, Awaitable[str]]] + ] = None """Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials.""" managed_identity_client_id: Optional[str] = None """ diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index 6c70ceecd..e061c9b52 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -14,6 +14,7 @@ Account, ActivityBase, ActivityParams, + AgenticIdentity, ApiClient, CardAction, CardActionType, @@ -102,6 +103,7 @@ def __init__( self.cloud = cloud self._activity_sender = activity_sender self._app_token = app_token + self.agentic_identity = self._get_agentic_identity() self.stream = activity_sender.create_stream(conversation_ref) self._next_handler: Optional[Callable[[], Awaitable[None]]] = None @@ -191,7 +193,11 @@ async def send( self._add_targeted_message_info_entity(activity) ref = conversation_ref or self.conversation_ref - res = await self._activity_sender.send(activity, ref) + if self.agentic_identity is not None: + ref = ref.model_copy(deep=True) + ref.bot = Account(id=self.agentic_identity.channel_account_id) + + res = await self._activity_sender.send(activity, ref, self.agentic_identity) return res async def reply(self, input: str | ActivityParams) -> SentActivity: @@ -246,6 +252,27 @@ def _incoming_targeted_sender(self) -> Optional[Account]: return self.activity.from_ + def _get_agentic_identity(self) -> Optional[AgenticIdentity]: + recipient = getattr(self.activity, "recipient", None) + if recipient is None or recipient.role != "agenticUser": + return None + + if not recipient.agentic_user_id: + raise ValueError("agenticUser recipient is missing agenticUserId") + if not recipient.agentic_app_id: + raise ValueError("agenticUser recipient is missing agenticAppId") + + tenant_id = recipient.tenant_id or getattr(self.activity.conversation, "tenant_id", None) + if not tenant_id: + raise ValueError("agenticUser recipient is missing tenantId") + + return AgenticIdentity( + agentic_app_id=recipient.agentic_app_id, + agentic_user_id=recipient.agentic_user_id, + tenant_id=tenant_id, + agentic_app_blueprint_id=recipient.agentic_app_blueprint_id, + ) + def _should_outbound_be_auto_targeted( self, activity: ActivityParams, diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py index feddfccb8..ccf0bdad1 100644 --- a/packages/apps/src/microsoft_teams/apps/token_manager.py +++ b/packages/apps/src/microsoft_teams/apps/token_manager.py @@ -5,11 +5,12 @@ import asyncio import logging -from inspect import isawaitable -from typing import Any, Callable, Optional +from inspect import Parameter, isawaitable, signature +from typing import Any, Awaitable, Callable, Optional, cast import requests from microsoft_teams.api import ( + AgenticIdentity, ClientCredentials, Credentials, JsonWebToken, @@ -111,6 +112,14 @@ async def get_agent_user_token( return None credentials = self._credentials + agentic_identity = AgenticIdentity( + agentic_app_id=agent_identity_app_id, + agentic_user_id=agent_user_id or agent_user_upn or "", + tenant_id=tenant_id, + ) + if isinstance(credentials, TokenCredentials): + return await self._get_token_with_token_provider(credentials, scope, tenant_id, agentic_identity) + if not isinstance(credentials, ClientCredentials): raise ValueError("Agent user tokens require ClientCredentials") confidential_client = self._get_confidential_client(credentials, tenant_id) @@ -246,9 +255,10 @@ async def _get_token_with_token_provider( credentials: TokenCredentials, scope: str, tenant_id: str, + agentic_identity: AgenticIdentity | None = None, ) -> TokenProtocol: """Get token using custom token provider function.""" - token = credentials.token(scope, tenant_id) + token = self._call_token_provider(credentials, scope, tenant_id, agentic_identity) if isawaitable(token): access_token = await token @@ -257,6 +267,27 @@ async def _get_token_with_token_provider( return JsonWebToken(access_token) + def _call_token_provider( + self, + credentials: TokenCredentials, + scope: str, + tenant_id: str, + agentic_identity: AgenticIdentity | None = None, + ) -> str | Awaitable[str]: + token_provider = cast(Any, credentials.token) + try: + parameters = list(signature(token_provider).parameters.values()) + except (TypeError, ValueError): + return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity)) + + accepts_agentic_identity = ( + any(parameter.kind == Parameter.VAR_POSITIONAL for parameter in parameters) or len(parameters) >= 3 + ) + if accepts_agentic_identity: + return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity)) + + return cast(str | Awaitable[str], token_provider(scope, tenant_id)) + def _handle_token_response(self, token_res: dict[str, Any], error_prefix: str = "") -> TokenProtocol: """Handle token response from MSAL client.""" if token_res.get("access_token", None):