Skip to content

Commit 4ea2c57

Browse files
Integrate agentic identity support into the SDK (#137)
* Implement asynchronous token retrieval methods in AgenticMsalAuth class * get_agentic_user_token implementation * get_agentic_user_token simplified implementation with ConfidentialClientApplication * Enhance AgenticMsalAuth: update get_agentic_instance_token to return tuple and add JWT decoding for blueprint ID * Supporting authorization variants * Continued auth refactor * Addressing continuation activity * Adding authorization tests * Passing Authorization tests * Basic AgenticAuthorization tests * Added AgenticAuthorization tests * Finalized fundamental unit tests for agentic auth scenarios * Formatting * Formatting * Address review comments and more breaking changes * Shifting around exchange token logic * Adding dynamic loading of connection and related tests * Aligning authorization handlers with how .NET does it * get_token_provider implemented and tested * Tested UserAuthorization and AgenticUserAuthorization classes * Finalized refactor tests * Sample compat * Compat changes * Passing all tests again * Repurposing SignInState * Completed tests for auth fix * Changes to avoid auth on typing * Changes to avoid auth on typing * Enable passing TurnContext into create_connector_client * Tweaks to work almost end-to-end / fixing connector client construction * Moving agentic static methods to be instance methods of Activity and fixing other tests * Addressing PR review comments * Reformatting files with black * Fixing test case * Removing unneeded subchannel constants * Logging blueprint id only if debugging
1 parent 13cfdf4 commit 4ea2c57

82 files changed

Lines changed: 4224 additions & 1599 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

libraries/microsoft-agents-activity/microsoft_agents/activity/_load_configuration.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from typing import Any, Dict
1+
from typing import Any
22

33

4-
def load_configuration_from_env(env_vars: Dict[str, Any]) -> dict:
4+
def load_configuration_from_env(env_vars: dict[str, Any]) -> dict:
55
"""
66
Parses environment variables and returns a dictionary with the relevant configuration.
77
"""
@@ -18,6 +18,11 @@ def load_configuration_from_env(env_vars: Dict[str, Any]) -> dict:
1818
current_level = current_level[next_level]
1919
last_level[levels[-1]] = value
2020

21+
if result.get("CONNECTIONSMAP") and isinstance(result["CONNECTIONSMAP"], dict):
22+
result["CONNECTIONSMAP"] = [
23+
conn for conn in result.get("CONNECTIONSMAP", {}).values()
24+
]
25+
2126
return {
2227
"AGENTAPPLICATION": result.get("AGENTAPPLICATION", {}),
2328
"CONNECTIONS": result.get("CONNECTIONS", {}),

libraries/microsoft-agents-activity/microsoft_agents/activity/activity.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .text_highlight import TextHighlight
2121
from .semantic_action import SemanticAction
2222
from .agents_model import AgentsModel
23+
from .role_types import RoleTypes
2324
from ._model_utils import pick_model, SkipNone
2425
from ._type_aliases import NonEmptyString
2526

@@ -648,3 +649,21 @@ def add_ai_metadata(
648649
self.entities = []
649650

650651
self.entities.append(ai_entity)
652+
653+
def is_agentic_request(self) -> bool:
654+
return self.recipient and self.recipient.role in [
655+
RoleTypes.agentic_identity,
656+
RoleTypes.agentic_user,
657+
]
658+
659+
def get_agentic_instance_id(self) -> Optional[str]:
660+
"""Gets the agent instance ID from the context if it's an agentic request."""
661+
if not self.is_agentic_request() or not self.recipient:
662+
return None
663+
return self.recipient.agentic_app_id
664+
665+
def get_agentic_user(self) -> Optional[str]:
666+
"""Gets the agentic user (UPN) from the context if it's an agentic request."""
667+
if not self.is_agentic_request() or not self.recipient:
668+
return None
669+
return self.recipient.id

libraries/microsoft-agents-activity/microsoft_agents/activity/channel_account.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ class ChannelAccount(AgentsModel):
2626
name: str = None
2727
aad_object_id: NonEmptyString = None
2828
role: NonEmptyString = None
29+
agentic_user_id: NonEmptyString = None
30+
agentic_app_id: NonEmptyString = None
31+
tenant_id: NonEmptyString = None
2932

3033
@property
3134
def properties(self) -> dict[str, Any]:

libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ class Channels(str, Enum):
1010
Ids of channels supported by ABS.
1111
"""
1212

13+
"""Agents channel."""
14+
agents = "agents"
15+
1316
console = "console"
1417
"""Console channel."""
1518

libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ class RoleTypes(str, Enum):
55
user = "user"
66
agent = "bot"
77
skill = "skill"
8+
agentic_identity = "agenticAppInstance"
9+
agentic_user = "agenticUser"

libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Copyright (c) Microsoft Corporation. All rights reserved.
22
# Licensed under the MIT License.
33

4+
import jwt
5+
46
from .agents_model import AgentsModel
57
from ._type_aliases import NonEmptyString
68

@@ -26,3 +28,19 @@ class TokenResponse(AgentsModel):
2628

2729
def __bool__(self):
2830
return bool(self.token)
31+
32+
def is_exchangeable(self) -> bool:
33+
"""
34+
Checks if a token is exchangeable (has api:// audience).
35+
36+
:param token: The token to check.
37+
:type token: str
38+
:return: True if the token is exchangeable, False otherwise.
39+
"""
40+
try:
41+
# Decode without verification to check the audience
42+
payload = jwt.decode(self.token, options={"verify_signature": False})
43+
aud = payload.get("aud")
44+
return isinstance(aud, str) and aud.startswith("api://")
45+
except Exception:
46+
return False

libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py

Lines changed: 207 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4+
import jwt
45
from typing import Optional
56
from urllib.parse import urlparse, ParseResult as URI
67
from msal import (
@@ -23,6 +24,18 @@
2324
logger = logging.getLogger(__name__)
2425

2526

27+
# this is deferred because jwt.decode is expensive and we don't want to do it unless we
28+
# have logging.DEBUG enabled
29+
class _DeferredLogOfBlueprintId:
30+
def __init__(self, jwt_token: str):
31+
self.jwt_token = jwt_token
32+
33+
def __str__(self):
34+
payload = jwt.decode(self.jwt_token, options={"verify_signature": False})
35+
agentic_blueprint_id = payload.get("xms_par_app_azp")
36+
return f"Agentic blueprint id: {agentic_blueprint_id}"
37+
38+
2639
class MsalAuth(AccessTokenProviderBase):
2740

2841
_client_credential_cache = None
@@ -56,11 +69,16 @@ async def get_access_token(
5669
auth_result_payload = msal_auth_client.acquire_token_for_client(
5770
scopes=local_scopes
5871
)
72+
else:
73+
auth_result_payload = None
5974

60-
# TODO: Handling token error / acquisition failed
61-
return auth_result_payload["access_token"]
75+
res = auth_result_payload.get("access_token") if auth_result_payload else None
76+
if not res:
77+
logger.error("Failed to acquire token for resource %s", auth_result_payload)
78+
raise ValueError(f"Failed to acquire token. {str(auth_result_payload)}")
79+
return res
6280

63-
async def aquire_token_on_behalf_of(
81+
async def acquire_token_on_behalf_of(
6482
self, scopes: list[str], user_assertion: str
6583
) -> str:
6684
"""
@@ -186,3 +204,189 @@ def _resolve_scopes_list(self, instance_url: URI, scopes=None) -> list[str]:
186204
temp_list.append(scope_placeholder)
187205
logger.debug(f"Resolved scopes: {temp_list}")
188206
return temp_list
207+
208+
# the call to MSAL is blocking, but in the future we want to create an asyncio task
209+
# to avoid this
210+
async def get_agentic_application_token(
211+
self, agent_app_instance_id: str
212+
) -> Optional[str]:
213+
"""Gets the agentic application token for the given agent application instance ID.
214+
215+
:param agent_app_instance_id: The agent application instance ID.
216+
:type agent_app_instance_id: str
217+
:return: The agentic application token, or None if not found.
218+
:rtype: Optional[str]
219+
"""
220+
221+
if not agent_app_instance_id:
222+
raise ValueError("Agent application instance Id must be provided.")
223+
224+
logger.info(
225+
"Attempting to get agentic application token from agent_app_instance_id %s",
226+
agent_app_instance_id,
227+
)
228+
msal_auth_client = self._create_client_application()
229+
230+
if isinstance(msal_auth_client, ConfidentialClientApplication):
231+
232+
# https://github.dev/AzureAD/microsoft-authentication-library-for-dotnet
233+
auth_result_payload = msal_auth_client.acquire_token_for_client(
234+
["api://AzureAdTokenExchange/.default"],
235+
data={"fmi_path": agent_app_instance_id},
236+
)
237+
238+
if auth_result_payload:
239+
return auth_result_payload.get("access_token")
240+
241+
return None
242+
243+
async def get_agentic_instance_token(
244+
self, agent_app_instance_id: str
245+
) -> tuple[str, str]:
246+
"""Gets the agentic instance token for the given agent application instance ID.
247+
248+
:param agent_app_instance_id: The agent application instance ID.
249+
:type agent_app_instance_id: str
250+
:return: A tuple containing the agentic instance token and the agent application token.
251+
:rtype: tuple[str, str]
252+
"""
253+
254+
if not agent_app_instance_id:
255+
raise ValueError("Agent application instance Id must be provided.")
256+
257+
logger.info(
258+
"Attempting to get agentic instance token from agent_app_instance_id %s",
259+
agent_app_instance_id,
260+
)
261+
agent_token_result = await self.get_agentic_application_token(
262+
agent_app_instance_id
263+
)
264+
265+
if not agent_token_result:
266+
logger.error(
267+
"Failed to acquire agentic instance token or agent token for agent_app_instance_id %s",
268+
agent_app_instance_id,
269+
)
270+
raise Exception(
271+
f"Failed to acquire agentic instance token or agent token for agent_app_instance_id {agent_app_instance_id}"
272+
)
273+
274+
authority = (
275+
f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}"
276+
)
277+
278+
instance_app = ConfidentialClientApplication(
279+
client_id=agent_app_instance_id,
280+
authority=authority,
281+
client_credential={"client_assertion": agent_token_result},
282+
)
283+
284+
agentic_instance_token = instance_app.acquire_token_for_client(
285+
["api://AzureAdTokenExchange/.default"]
286+
)
287+
288+
if not agentic_instance_token:
289+
logger.error(
290+
"Failed to acquire agentic instance token or agent token for agent_app_instance_id %s",
291+
agent_app_instance_id,
292+
)
293+
raise Exception(
294+
f"Failed to acquire agentic instance token or agent token for agent_app_instance_id {agent_app_instance_id}"
295+
)
296+
297+
# future scenario where we don't know the blueprint id upfront
298+
299+
token = agentic_instance_token.get("access_token")
300+
if not token:
301+
logger.error(
302+
"Failed to acquire agentic instance token, %s", agentic_instance_token
303+
)
304+
raise ValueError(f"Failed to acquire token. {str(agentic_instance_token)}")
305+
306+
logger.debug(_DeferredLogOfBlueprintId(token))
307+
308+
return agentic_instance_token["access_token"], agent_token_result
309+
310+
async def get_agentic_user_token(
311+
self, agent_app_instance_id: str, upn: str, scopes: list[str]
312+
) -> Optional[str]:
313+
"""Gets the agentic user token for the given agent application instance ID and user principal name and the scopes.
314+
315+
:param agent_app_instance_id: The agent application instance ID.
316+
:type agent_app_instance_id: str
317+
:param upn: The user principal name.
318+
:type upn: str
319+
:param scopes: The scopes to request for the token.
320+
:type scopes: list[str]
321+
:return: The agentic user token, or None if not found.
322+
:rtype: Optional[str]
323+
"""
324+
if not agent_app_instance_id or not upn:
325+
raise ValueError(
326+
"Agent application instance Id and user principal name must be provided."
327+
)
328+
329+
logger.info(
330+
"Attempting to get agentic user token from agent_app_instance_id %s and upn %s",
331+
agent_app_instance_id,
332+
upn,
333+
)
334+
instance_token, agent_token = await self.get_agentic_instance_token(
335+
agent_app_instance_id
336+
)
337+
338+
if not instance_token or not agent_token:
339+
logger.error(
340+
"Failed to acquire instance token or agent token for agent_app_instance_id %s and upn %s",
341+
agent_app_instance_id,
342+
upn,
343+
)
344+
raise Exception(
345+
f"Failed to acquire instance token or agent token for agent_app_instance_id {agent_app_instance_id} and upn {upn}"
346+
)
347+
348+
authority = (
349+
f"https://login.microsoftonline.com/{self._msal_configuration.TENANT_ID}"
350+
)
351+
352+
instance_app = ConfidentialClientApplication(
353+
client_id=agent_app_instance_id,
354+
authority=authority,
355+
client_credential={"client_assertion": agent_token},
356+
)
357+
358+
logger.info(
359+
"Acquiring agentic user token for agent_app_instance_id %s and upn %s",
360+
agent_app_instance_id,
361+
upn,
362+
)
363+
auth_result_payload = instance_app.acquire_token_for_client(
364+
scopes,
365+
data={
366+
"username": upn,
367+
"user_federated_identity_credential": instance_token,
368+
"grant_type": "user_fic",
369+
},
370+
)
371+
372+
if not auth_result_payload:
373+
logger.error(
374+
"Failed to acquire agentic user token for agent_app_instance_id %s and upn %s, %s",
375+
agent_app_instance_id,
376+
upn,
377+
auth_result_payload,
378+
)
379+
return None
380+
381+
access_token = auth_result_payload.get("access_token")
382+
if not access_token:
383+
logger.error(
384+
"Failed to acquire agentic user token for agent_app_instance_id %s and upn %s, %s",
385+
agent_app_instance_id,
386+
upn,
387+
auth_result_payload,
388+
)
389+
return None
390+
391+
logger.info("Acquired agentic user token response.")
392+
return access_token

0 commit comments

Comments
 (0)