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
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@


class ChannelFactoryProtocol(Protocol):

def create_channel(self, token_access: AccessTokenProviderBase) -> ChannelProtocol:
Comment thread
Copilot marked this conversation as resolved.
pass
raise NotImplementedError("create_channel must be implemented by subclasses")
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

from typing import Any
from urllib.parse import urlparse

from kiota_abstractions.request_information import RequestInformation
from kiota_abstractions.authentication import AuthenticationProvider
Expand All @@ -17,11 +18,15 @@

from microsoft_agents.hosting.core import (
AgentApplication,
Authorization,
TurnContext,
AccessTokenProviderBase,
)

_DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"

class _SDKAuthenticationProvider(AuthenticationProvider):

class _SDKUserAuthenticationProvider(AuthenticationProvider):
"""Kiota authentication provider backed by the agent's authorization.

Acquires an access token for the current turn via
Expand All @@ -31,17 +36,17 @@ class _SDKAuthenticationProvider(AuthenticationProvider):

def __init__(
self,
app: AgentApplication,
auth: Authorization,
context: TurnContext,
handler_name: str | None = None,
):
"""Capture the context needed to resolve a token at request time.

:param app: The agent application whose authorization issues tokens.
:param auth: The agent application's authorization.
:param context: The current turn context.
:param handler_name: The auth handler name used to acquire the token.
:param handler_name: Optional name of the handler to use for authentication.
"""
self._app = app
self._auth = auth
self._context = context
self._handler_name = handler_name

Expand All @@ -59,28 +64,130 @@ async def authenticate_request(
if additional_authentication_context is None:
additional_authentication_context = {}

token_response = await self._app.auth.get_token(
self._context, self._handler_name
token_response = await self._auth.get_token(self._context, self._handler_name)
if token_response and token_response.token:
request.headers.add("Authorization", f"Bearer {token_response.token}")


class _SDKAuthenticationProvider(AuthenticationProvider):

def __init__(
self,
token_provider: AccessTokenProviderBase,
resource_url: str,
scopes: list[str],
):
"""Capture the context needed to resolve a token at request time.

:param token_provider: The access token provider for the agent application.
:param resource_url: The resource URL for which to acquire the token.
:param scopes: The scopes for which to acquire the token.
"""
self._token_provider = token_provider
self._resource_url = resource_url
self._scopes = scopes

async def authenticate_request(
self,
request: RequestInformation,
additional_authentication_context: dict[str, Any] | None = None,
) -> None:
"""Attach a bearer token to the outgoing Graph request.

:param request: The request to authenticate.
:param additional_authentication_context: Optional Kiota authentication
context; unused but accepted to satisfy the provider interface.
"""
if additional_authentication_context is None:
additional_authentication_context = {}

token = await self._token_provider.get_access_token(
self._resource_url, self._scopes
)
if token_response:
request.headers["Authorization"] = f"Bearer {token_response.token}"
if token:
request.headers.add("Authorization", f"Bearer {token}")


def _create_graph_service_client(
def _create_user_graph_service_client(
app: AgentApplication,
context: TurnContext,
handler_name: str | None = None,
graph_base_url: str = _DEFAULT_GRAPH_BASE_URL,
) -> GraphServiceClient:
"""Create a Graph client authenticated for the current turn.

:param app: The agent application whose authorization issues tokens.
:param context: The current turn context.
:param handler_name: Optional auth handler name used to acquire the token.
:return: A :class:`GraphServiceClient` that authenticates each request via
the agent's authorization.
the agent's connections.
Comment thread
rodrigobr-msft marked this conversation as resolved.
"""
return GraphServiceClient(
request_adapter=GraphRequestAdapter(
_SDKAuthenticationProvider(app, context, handler_name)
)
adapter = GraphRequestAdapter(
_SDKUserAuthenticationProvider(app.auth, context, handler_name)
)
adapter.base_url = graph_base_url.rstrip("/") + "/"
return GraphServiceClient(request_adapter=adapter)


def _create_app_graph_service_client(
token_provider: AccessTokenProviderBase,
graph_base_url: str,
) -> GraphServiceClient:
"""Create a Graph client authenticated for the agent application.

:param token_provider: The access token provider for the agent application.
:param graph_base_url: The base URL for the Graph API.
:return: A :class:`GraphServiceClient` that authenticates each request via
the token provider.
"""
url_parsed = urlparse(graph_base_url)
resource_url = f"{url_parsed.scheme}://{url_parsed.netloc}"
Comment thread
rodrigobr-msft marked this conversation as resolved.
scopes = [f"{resource_url}/.default"]
request_adapter = GraphRequestAdapter(
_SDKAuthenticationProvider(token_provider, resource_url, scopes)
)
request_adapter.base_url = graph_base_url.rstrip("/") + "/"
return GraphServiceClient(request_adapter=request_adapter)


def _common_get_app_graph_client(
app: AgentApplication,
context: TurnContext,
graph_base_url: str = _DEFAULT_GRAPH_BASE_URL,
) -> GraphServiceClient:
"""Get a Graph client authenticated for the agent application.

:param app: The agent application whose authorization issues tokens.
:param context: The current turn context.
:param graph_base_url: The base URL for the Graph API.
:return: A :class:`GraphServiceClient` that authenticates each request via
the agent's connections.
"""
Comment thread
rodrigobr-msft marked this conversation as resolved.
if not context.identity:
raise ValueError("TurnContext.identity is required to get a Graph client.")
token_provider = app.connection_manager.get_token_provider(
context.identity, context.activity.service_url
)
Comment thread
rodrigobr-msft marked this conversation as resolved.
return _create_app_graph_service_client(token_provider, graph_base_url)
Comment on lines +166 to +171


def _common_get_app_graph_client_for_connection(
app: AgentApplication,
connection_name: str | None = None,
graph_base_url: str = _DEFAULT_GRAPH_BASE_URL,
) -> GraphServiceClient:
Comment thread
rodrigobr-msft marked this conversation as resolved.
"""Get a Graph client authenticated for the agent application.

:param app: The agent application whose authorization issues tokens.
:param connection_name: Optional connection name to select a specific token provider.
:param graph_base_url: The base URL for the Graph API.
:return: A :class:`GraphServiceClient` that authenticates each request via
the token provider from the connection.
"""
token_provider: AccessTokenProviderBase
if not connection_name:
token_provider = app.connection_manager.get_default_connection()
else:
token_provider = app.connection_manager.get_connection(connection_name)

return _create_app_graph_service_client(token_provider, graph_base_url)
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@
from .task_module import TaskModule
from .team import Team

from ._graph import _create_graph_service_client
from ._graph import (
_DEFAULT_GRAPH_BASE_URL,
_create_user_graph_service_client,
_common_get_app_graph_client,
_common_get_app_graph_client_for_connection,
)

from ._teams_api_client import (
_get_teams_api_client,
_set_teams_api_client,
Expand Down Expand Up @@ -298,12 +304,44 @@ def get_teams_api_client(self, context: TurnContext) -> ApiClient:
return _get_teams_api_client(context)

def get_graph_client(
self, context: TurnContext, handler_name: str | None = None
self,
context: TurnContext,
handler_name: str | None = None,
graph_base_url: str = _DEFAULT_GRAPH_BASE_URL,
) -> GraphServiceClient:
"""Get the Graph Service client.

:param context: The turn context.
:param handler_name: The name of the handler.
:param graph_base_url: The base URL for the Microsoft Graph API.
Comment thread
rodrigobr-msft marked this conversation as resolved.
:return: The Graph Service client.
"""
return _create_graph_service_client(self._app, context, handler_name)
return _create_user_graph_service_client(
self._app, context, handler_name, graph_base_url=graph_base_url
)

def get_app_graph_client(
self, context: TurnContext, graph_base_url: str = _DEFAULT_GRAPH_BASE_URL
) -> GraphServiceClient:
Comment thread
rodrigobr-msft marked this conversation as resolved.
"""Get the Graph Service client for the agent application.

:param context: The turn context.
:param graph_base_url: The base URL for the Microsoft Graph API.
:return: The Graph Service client.
"""
Comment thread
rodrigobr-msft marked this conversation as resolved.
return _common_get_app_graph_client(
self._app, context, graph_base_url=graph_base_url
)

def get_app_graph_client_for_connection(
self, connection_name: str, graph_base_url: str = _DEFAULT_GRAPH_BASE_URL
) -> GraphServiceClient:
"""Get the Graph Service client for the agent application using a specific connection.

:param connection_name: The name of the connection to use for authentication.
:param graph_base_url: The base URL for the Microsoft Graph API.
:return: The Graph Service client.
"""
Comment thread
rodrigobr-msft marked this conversation as resolved.
return _common_get_app_graph_client_for_connection(
self._app, connection_name=connection_name, graph_base_url=graph_base_url
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

from typing import cast

from msgraph import GraphServiceClient

from microsoft_teams.api import ApiClient

from microsoft_agents.activity import (
Expand All @@ -15,8 +17,17 @@
ActivityTreatmentTypes,
ResourceResponse,
)
from microsoft_agents.hosting.core import AgentApplication, TurnContext
from microsoft_agents.hosting.core import (
AgentApplication,
TurnContext,
)
Comment thread
Copilot marked this conversation as resolved.

from ._graph import (
_DEFAULT_GRAPH_BASE_URL,
_create_user_graph_service_client,
_common_get_app_graph_client,
_common_get_app_graph_client_for_connection,
)
from ._teams_api_client import _get_teams_api_client, _set_teams_api_client
from .teams_activity import TeamsActivity

Expand Down Expand Up @@ -122,3 +133,55 @@ async def send_targeted_activities(
for activity in activities:
TeamsTurnContext._make_targeted_activity(activity)
return await self.send_activities(activities)

def get_graph_client(
self,
handler_name: str | None = None,
graph_base_url: str = _DEFAULT_GRAPH_BASE_URL,
) -> GraphServiceClient:
"""
Get a Graph client for the current turn context.

:param handler_name: Optional name of the handler to use for authentication.
:param graph_base_url: The base URL for the Microsoft Graph API.

:return: A :class:`GraphServiceClient` that authenticates each request via
the agent's authorization.
"""
return _create_user_graph_service_client(
self._app, self, handler_name, graph_base_url=graph_base_url
)

def get_app_graph_client(
self,
graph_base_url: str = _DEFAULT_GRAPH_BASE_URL,
) -> GraphServiceClient:
Comment thread
rodrigobr-msft marked this conversation as resolved.
"""
Get a Graph client for the current turn context.

:param graph_base_url: The base URL for the Microsoft Graph API.

Comment thread
rodrigobr-msft marked this conversation as resolved.
:return: A :class:`GraphServiceClient` that authenticates each request via
the agent's connections.
"""
return _common_get_app_graph_client(
self._app, self, graph_base_url=graph_base_url
)

def get_app_graph_client_for_connection(
self,
connection_name: str,
graph_base_url: str = _DEFAULT_GRAPH_BASE_URL,
) -> GraphServiceClient:
"""
Get a Graph client for the current turn context using a specific connection.

:param connection_name: The name of the connection to use for authentication.
:param graph_base_url: The base URL for the Microsoft Graph API.

:return: A :class:`GraphServiceClient` that authenticates each request via
the agent's connections.
"""
return _common_get_app_graph_client_for_connection(
self._app, connection_name, graph_base_url=graph_base_url
)
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
from microsoft_agents.hosting.msteams import TeamsAgentExtension
from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
load_dotenv()

Expand Down
32 changes: 32 additions & 0 deletions test_samples/hosting_msteams/graph-clients/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Teams Graph Clients Sample

Demonstrates creating and using Microsoft Graph clients from Teams route
handlers:

| Command | Graph client | Endpoint |
|---------|--------------|----------|
| `/appgraph apps` | app-only client | `GET /applications?$top=1&$select=id,appId,displayName` |
| `/usergraph me` | delegated user client | `GET /me?$select=id,displayName,userPrincipalName,mail` |
| `/signout` | delegated user auth handler | signs out the `GRAPH` handler |
Comment thread
rodrigobr-msft marked this conversation as resolved.

## Permissions

The app-only command uses the service connection credentials and requires
Microsoft Graph application permissions such as `Application.Read.All` with
admin consent.

The user command uses the configured Azure Bot OAuth connection and requires
delegated Microsoft Graph permissions such as `User.Read`.

## Running

1. Copy `env.TEMPLATE` to `.env`.
2. Fill in your bot/app credentials and configure the `GRAPH` OAuth connection.
3. Start the sample:

```powershell
python -m src.main
```

The server listens on `http://localhost:3978/api/messages`. Expose it with a
dev tunnel and side-load the app manifest into Teams.
8 changes: 8 additions & 0 deletions test_samples/hosting_msteams/graph-clients/env.TEMPLATE
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=
CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=
CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=

AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__OBOCONNECTIONNAME=SERVICE_CONNECTION
AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=

LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO
Loading
Loading