From fb6584a1926b5af4e657161e168c7a9c9fab5137 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 9 Oct 2025 10:27:25 -0700 Subject: [PATCH 01/21] Adding JWK client initialization code to cache keys --- .../core/authorization/jwt_token_validator.py | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py index 399e101f..5badf7a9 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py @@ -15,6 +15,8 @@ class JwtTokenValidator: def __init__(self, configuration: AgentAuthConfiguration): self.configuration = configuration + self._default_jwks_client = None + self._tenant_jwks_client = None def validate_token(self, token: str) -> ClaimsIdentity: @@ -38,17 +40,45 @@ def validate_token(self, token: str) -> ClaimsIdentity: def get_anonymous_claims(self) -> ClaimsIdentity: logger.debug("Returning anonymous claims identity.") return ClaimsIdentity({}, False, authentication_type="Anonymous") + + def _get_client(self, issuer: str) -> PyJWKClient: + client = None + if issuer == "https://api.botframework.com": + client = self._default_jwks_client + else: + client = self._tenant_jwks_client + if not client: + raise RuntimeError("JWKS client is not initialized.") + return client + + def _init_jwks_client(self, issuer: str) -> None: + + client_options = { + "cache_keys": True + } + + if issuer == "https://api.botframework.com": + if self._default_jwks_client is None: + self._default_jwks_client = PyJWKClient( + "https://login.botframework.com/v1/.well-known/keys", + **client_options + ) + else: + if self._tenant_jwks_client is None: + self._tenant_jwks_client = PyJWKClient( + f"https://login.microsoftonline.com/{self.configuration.TENANT_ID}/discovery/v2.0/keys", + **client_options + ) def _get_public_key_or_secret(self, token: str) -> PyJWK: + header = get_unverified_header(token) unverified_payload: dict = decode(token, options={"verify_signature": False}) - jwksUri = ( - "https://login.botframework.com/v1/.well-known/keys" - if unverified_payload.get("iss") == "https://api.botframework.com" - else f"https://login.microsoftonline.com/{self.configuration.TENANT_ID}/discovery/v2.0/keys" - ) - jwks_client = PyJWKClient(jwksUri) + issuer = unverified_payload.get("iss") + if not issuer: + raise ValueError("Issuer (iss) claim is missing in the token.") + self._init_jwks_client(issuer) - key = jwks_client.get_signing_key(header["kid"]) + key = self._get_client(issuer).get_signing_key(header["kid"]) return key From 87870307c7433ecfda7834ad192fdd4d7a57d7fd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 13 Oct 2025 14:06:18 -0700 Subject: [PATCH 02/21] Adding click as a dev dependency --- dev_dependencies.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev_dependencies.txt b/dev_dependencies.txt index f1e775ef..86b3d4c8 100644 --- a/dev_dependencies.txt +++ b/dev_dependencies.txt @@ -1,4 +1,5 @@ pytest pytest-asyncio pytest-mock -pre-commit \ No newline at end of file +pre-commit +click \ No newline at end of file From 34725d3ebbb22cc7863d29c6780fe980b1c8e7ad Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 13 Oct 2025 14:37:22 -0700 Subject: [PATCH 03/21] Wrapping JWT keys request with asyncio functionality to not block thread --- .../aiohttp/jwt_authorization_middleware.py | 4 +- .../core/authorization/jwt_token_validator.py | 53 +++++++++++++++++-- 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py index d28618cd..a30a95e5 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py @@ -17,7 +17,7 @@ async def jwt_authorization_middleware(request: Request, handler): # Extract the token from the Authorization header token = auth_header.split(" ")[1] try: - claims = token_validator.validate_token(token) + claims = await token_validator.validate_token(token) request["claims_identity"] = claims except ValueError as e: print(f"JWT validation error: {e}") @@ -44,7 +44,7 @@ async def wrapper(request): # Extract the token from the Authorization header token = auth_header.split(" ")[1] try: - claims = token_validator.validate_token(token) + claims = await token_validator.validate_token(token) request["claims_identity"] = claims except ValueError as e: print(f"JWT validation error: {e}") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py index 5badf7a9..f41fc1fa 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import asyncio import logging import jwt @@ -13,15 +14,34 @@ class JwtTokenValidator: + """Validates JWT tokens issued by Azure AD.""" + def __init__(self, configuration: AgentAuthConfiguration): + """Initialize the JwtTokenValidator with the given configuration. + + :param configuration: The AgentAuthConfiguration instance containing settings. + :type configuration: AgentAuthConfiguration + :raises ValueError: If configuration is None. + """ + if not configuration: + raise ValueError("Configuration cannot be None.") + self.configuration = configuration self._default_jwks_client = None self._tenant_jwks_client = None - def validate_token(self, token: str) -> ClaimsIdentity: + async def validate_token(self, token: str) -> ClaimsIdentity: + """Validate the given JWT token and return a ClaimsIdentity. + + :param token: The JWT token to validate. + :type token: str + :return: A ClaimsIdentity representing the validated token. + :rtype: ClaimsIdentity + :raises ValueError: If the token is invalid or the audience does not match. + """ logger.debug("Validating JWT token.") - key = self._get_public_key_or_secret(token) + key = await self._get_public_key_or_secret(token) decoded_token = jwt.decode( token, key=key, @@ -38,10 +58,19 @@ def validate_token(self, token: str) -> ClaimsIdentity: return ClaimsIdentity(decoded_token, True) def get_anonymous_claims(self) -> ClaimsIdentity: + """Return an anonymous ClaimsIdentity.""" logger.debug("Returning anonymous claims identity.") return ClaimsIdentity({}, False, authentication_type="Anonymous") def _get_client(self, issuer: str) -> PyJWKClient: + """Get the appropriate JWKS client based on the issuer. + + :param issuer: The issuer URL from the token. + :type issuer: str + :return: The corresponding PyJWKClient instance. + :rtype: PyJWKClient + """ + client = None if issuer == "https://api.botframework.com": client = self._default_jwks_client @@ -52,6 +81,11 @@ def _get_client(self, issuer: str) -> PyJWKClient: return client def _init_jwks_client(self, issuer: str) -> None: + """Initialize the JWKS client based on the issuer. + + :param issuer: The issuer URL from the token. + :type issuer: str + """ client_options = { "cache_keys": True @@ -70,7 +104,15 @@ def _init_jwks_client(self, issuer: str) -> None: **client_options ) - def _get_public_key_or_secret(self, token: str) -> PyJWK: + async def _get_public_key_or_secret(self, token: str) -> PyJWK: + """Extract the public key or secret from the JWT token. + + :param token: The JWT token. + :type token: str + :return: The public key or secret used to verify the token. + :rtype: PyJWK + :raises ValueError: If the issuer claim is missing in the token. + """ header = get_unverified_header(token) unverified_payload: dict = decode(token, options={"verify_signature": False}) @@ -79,6 +121,9 @@ def _get_public_key_or_secret(self, token: str) -> PyJWK: if not issuer: raise ValueError("Issuer (iss) claim is missing in the token.") self._init_jwks_client(issuer) + + def func(): + return self._get_client(issuer).get_signing_key(header["kid"]) + key = await asyncio.to_thread(func) - key = self._get_client(issuer).get_signing_key(header["kid"]) return key From ef48ded8e4aae46f7eed323c7b619ac0e5c05103 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 14 Oct 2025 10:42:09 -0700 Subject: [PATCH 04/21] Adding simple benchmarking tool --- dev/README.md | 6 ++ dev/benchmark/README.md | 48 +++++++++++++++ dev/benchmark/payload.json | 4 ++ dev/benchmark/requirements.txt | 3 + dev/benchmark/src/__init__.py | 0 dev/benchmark/src/aggregated_results.py | 14 +++++ dev/benchmark/src/executor/__init__.py | 11 ++++ .../src/executor/coroutine_executor.py | 19 ++++++ .../src/executor/execution_result.py | 27 ++++++++ dev/benchmark/src/executor/executor.py | 44 +++++++++++++ dev/benchmark/src/executor/thread_executor.py | 34 +++++++++++ dev/benchmark/src/main.py | 61 +++++++++++++++++++ dev/benchmark/src/payload_sender.py | 27 ++++++++ .../_handlers/agentic_user_authorization.py | 2 +- .../core/authorization/jwt_token_validator.py | 1 - 15 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 dev/README.md create mode 100644 dev/benchmark/README.md create mode 100644 dev/benchmark/payload.json create mode 100644 dev/benchmark/requirements.txt create mode 100644 dev/benchmark/src/__init__.py create mode 100644 dev/benchmark/src/aggregated_results.py create mode 100644 dev/benchmark/src/executor/__init__.py create mode 100644 dev/benchmark/src/executor/coroutine_executor.py create mode 100644 dev/benchmark/src/executor/execution_result.py create mode 100644 dev/benchmark/src/executor/executor.py create mode 100644 dev/benchmark/src/executor/thread_executor.py create mode 100644 dev/benchmark/src/main.py create mode 100644 dev/benchmark/src/payload_sender.py diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 00000000..966b863e --- /dev/null +++ b/dev/README.md @@ -0,0 +1,6 @@ +This directory contains tools to aid the developers of the Microsoft 365 Agents SDK for Python. + +### `benchmark` + +This folder contains benchmarking utilities built in Python to send concurrent threads +to an agent. \ No newline at end of file diff --git a/dev/benchmark/README.md b/dev/benchmark/README.md new file mode 100644 index 00000000..e24a8d62 --- /dev/null +++ b/dev/benchmark/README.md @@ -0,0 +1,48 @@ +A simple benchmarking tool. + +## Benchmark Setup (Windows) + +Traditionally, most Python versions have a global interpreter lock (GIL) which prevents +more than 1 thread to run at the same time. With 3.13, there are free-threaded versions +of Python which allow one to bypass this constraint. This section walks through how +to do that on Windows. Use PowerShell. + +Based on: https://docs.python.org/3/using/windows.html# + +Go to `Microsoft Store` and install `Python Install Manager` and follow the instructions +presented. You may have to make certain changes to alias used by your machine (that +should be guided by the installation process). + +Based on: https://docs.python.org/3/whatsnew/3.13.html#free-threaded-cpython + +In PowerShell, install the free-threaded version of Python of your choice. In this guide +we will install `3.14t`: + +```bash +py install 3.14t +``` + +Then, set up and activate the virtual environment with: + +```bash +python3.14t -m venv venv +. ./venv/Scripts/activate +pip install -r requirements.txt +``` + +To activate the virtual environment, use: + +```bash +. ./venv/Scripts/activate +``` + +To deactivate it, you may use: + +```bash +deactivate +``` + +## Complete Setup + +Running these tests requires you to have the agent running in a separate process. You +may open a separate PowerShell window or VSCode window and run your agent there. \ No newline at end of file diff --git a/dev/benchmark/payload.json b/dev/benchmark/payload.json new file mode 100644 index 00000000..352241e0 --- /dev/null +++ b/dev/benchmark/payload.json @@ -0,0 +1,4 @@ +{ + "type": "message", + "text": "Hello world!" +} \ No newline at end of file diff --git a/dev/benchmark/requirements.txt b/dev/benchmark/requirements.txt new file mode 100644 index 00000000..f8fa0cbd --- /dev/null +++ b/dev/benchmark/requirements.txt @@ -0,0 +1,3 @@ +microsoft-agents-activity +microsoft-agents-hosting-core +click \ No newline at end of file diff --git a/dev/benchmark/src/__init__.py b/dev/benchmark/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dev/benchmark/src/aggregated_results.py b/dev/benchmark/src/aggregated_results.py new file mode 100644 index 00000000..8faefcdd --- /dev/null +++ b/dev/benchmark/src/aggregated_results.py @@ -0,0 +1,14 @@ +from .executor import ExecutionResult + +class AggregatedResults: + """Class to analyze execution time results.""" + + def __init__(self, results: list[ExecutionResult]): + self._results = results + + self.average = sum(r.duration for r in results) / len(results) if results else 0 + self.min = min((r.duration for r in results), default=0) + self.max = max((r.duration for r in results), default=0) + self.success_count = sum(1 for r in results if r.success) + self.failure_count = len(results) - self.success_count + self.total_time = sum(r.duration for r in results) \ No newline at end of file diff --git a/dev/benchmark/src/executor/__init__.py b/dev/benchmark/src/executor/__init__.py new file mode 100644 index 00000000..88da0e48 --- /dev/null +++ b/dev/benchmark/src/executor/__init__.py @@ -0,0 +1,11 @@ +from .coroutine_executor import CoroutineExecutor +from .execution_result import ExecutionResult +from .executor import Executor +from .thread_executor import ThreadExecutor + +__all__ = [ + "CoroutineExecutor", + "ExecutionResult", + "Executor", + "ThreadExecutor", +] \ No newline at end of file diff --git a/dev/benchmark/src/executor/coroutine_executor.py b/dev/benchmark/src/executor/coroutine_executor.py new file mode 100644 index 00000000..b4b9d8fb --- /dev/null +++ b/dev/benchmark/src/executor/coroutine_executor.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio +from typing import Callable, Awaitable, Any + +from .executor import Executor +from .execution_result import ExecutionResult + +class CoroutineExecutor(Executor): + """An executor that runs asynchronous functions using asyncio.""" + + def run(self, func: Callable[[], Awaitable[Any]], num_workers: int = 1) -> list[ExecutionResult]: + """Run the given asynchronous function using the specified number of threads. + + :param func: An asynchronous function to be executed. + :param num_workers: The number of concurrent threads to use. + """ + return asyncio.run(asyncio.gather(*(self.run_func(i, func) for i in range(num_workers)))) \ No newline at end of file diff --git a/dev/benchmark/src/executor/execution_result.py b/dev/benchmark/src/executor/execution_result.py new file mode 100644 index 00000000..8a1583c2 --- /dev/null +++ b/dev/benchmark/src/executor/execution_result.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any, Optional +from dataclasses import dataclass + +@dataclass +class ExecutionResult: + """Class to represent the result of an execution.""" + + exe_id: int + + start_time: float + end_time: float + + result: Any = None + error: Optional[Exception] = None + + @property + def success(self) -> bool: + """Indicate whether the execution was successful.""" + return self.error is None + + @property + def duration(self) -> float: + """Calculate the duration of the execution, in seconds.""" + return self.end_time - self.start_time \ No newline at end of file diff --git a/dev/benchmark/src/executor/executor.py b/dev/benchmark/src/executor/executor.py new file mode 100644 index 00000000..9ed40d09 --- /dev/null +++ b/dev/benchmark/src/executor/executor.py @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime, timezone +from abc import ABC, abstractmethod +from typing import Callable, Awaitable, Any + +from .execution_result import ExecutionResult + +class Executor(ABC): + """Protocol for executing asynchronous functions concurrently.""" + + async def run_func(self, exe_id: int, func: Callable[[], Awaitable[Any]]) -> ExecutionResult: + """Run the given asynchronous function. + + :param exe_id: An identifier for the execution instance. + :param func: An asynchronous function to be executed. + """ + + start_time = datetime.now(timezone.utc).timestamp() + try: + result = await func() + return ExecutionResult( + exe_id=exe_id, + result=result, + start_time=start_time, + end_time=datetime.now(timezone.utc).timestamp() + ) + except Exception as e: # pylint: disable=broad-except + return ExecutionResult( + exe_id=exe_id, + error=e, + start_time=start_time, + end_time=datetime.now(timezone.utc).timestamp() + ) + + @abstractmethod + def run(self, func: Callable[[], Awaitable[None]], num_workers: int = 1) -> list[ExecutionResult]: + """Run the given asynchronous function using the specified number of workers. + + :param func: An asynchronous function to be executed. + :param num_workers: The number of concurrent workers to use. + """ + raise NotImplementedError("This method should be implemented by subclasses.") \ No newline at end of file diff --git a/dev/benchmark/src/executor/thread_executor.py b/dev/benchmark/src/executor/thread_executor.py new file mode 100644 index 00000000..4e119be9 --- /dev/null +++ b/dev/benchmark/src/executor/thread_executor.py @@ -0,0 +1,34 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging +import asyncio +from typing import Callable, Awaitable, Any +from concurrent.futures import ThreadPoolExecutor + +from .executor import Executor +from .execution_result import ExecutionResult + +logger = logging.getLogger(__name__) + +class ThreadExecutor(Executor): + """An executor that runs asynchronous functions using multiple threads.""" + + def run(self, func: Callable[[], Awaitable[Any]], num_workers: int = 1) -> list[ExecutionResult]: + """Run the given asynchronous function using the specified number of threads. + + :param func: An asynchronous function to be executed. + :param num_workers: The number of concurrent threads to use. + """ + + def _func(exe_id: int) -> ExecutionResult: + return asyncio.run(self.run_func(exe_id, func)) + + results: list[ExecutionResult] = [] + + with ThreadPoolExecutor(max_workers=num_workers) as executor: + futures = [executor.submit(lambda: _func(i)) for i in range(num_workers)] + for future in futures: + results.append(future.result()) + + return results \ No newline at end of file diff --git a/dev/benchmark/src/main.py b/dev/benchmark/src/main.py new file mode 100644 index 00000000..9985c3a2 --- /dev/null +++ b/dev/benchmark/src/main.py @@ -0,0 +1,61 @@ +import json +import logging +from datetime import datetime, timezone + +import click +from dotenv import load_dotenv + +from .payload_sender import create_payload_sender +from .executor import Executor, CoroutineExecutor, ThreadExecutor +from .aggregated_results import AggregatedResults + +load_dotenv() + +LOG_FORMAT = "%(asctime)s: %(message)s" +logging.basicConfig( + format=LOG_FORMAT, + level=logging.INFO, + datefmt="%H:%M:%S" +) + +@click.command() +@click.option("--payload_path", default="./payload.json", help="Path to the payload file.") +@click.option("--num-workers", default=1, help="Number of workers to use.") +@click.option("--async_mode", is_flag=True, help="Run coroutine workers rather than thread workers.") +def main( + payload_path: str, + num_workers: int, + async_mode: bool +): + """Main function to run the benchmark.""" + + with open(payload_path, "r") as f: + payload = json.load(f) + + func = create_payload_sender(payload) + + executor: Executor = CoroutineExecutor() if async_mode else ThreadExecutor() + + start_time = datetime.now(timezone.utc) + results = executor.run(func, num_workers=num_workers) + end_time = datetime.now(timezone.utc) + + agg = AggregatedResults(results) + + print() + print("---- Aggregated Results ----") + print() + print(f"Average Time: {agg.average:.4f} seconds") + print(f"Min Time: {agg.min:.4f} seconds") + print(f"Max Time: {agg.max:.4f} seconds") + print() + print(f"Success Rate: {agg.success_count} / {len(results)}") + print() + print(f"Total Time: {end_time - start_time} seconds") + print("----------------------------") + print() + + print(results[0]) + +if __name__ == "__main__": + main() # pylint: disable=no-value-for-parameter \ No newline at end of file diff --git a/dev/benchmark/src/payload_sender.py b/dev/benchmark/src/payload_sender.py new file mode 100644 index 00000000..28a90555 --- /dev/null +++ b/dev/benchmark/src/payload_sender.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +import json +import requests +from typing import Callable, Awaitable, Any + +def create_payload_sender(payload: dict[str, Any]) -> Callable[..., Awaitable[None]]: + + JWT_TOKEN = os.environ.get("JWT_TOKEN") + ENDPOINT = "http://localhost:3978/api/messages" + HEADERS = { + "Authorization": f"Bearer {JWT_TOKEN}", + "Content-Type": "application/json" + } + + async def payload_sender() -> Any: + + response = requests.post( + ENDPOINT, + data=payload, + headers=HEADERS + ) + return response.content + + return payload_sender \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index 7c8a5a30..9bb9066e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -170,7 +170,7 @@ async def get_refreshed_token( """Attempts to get a refreshed token for the user with the given scopes :param context: The turn context for the current turn of conversation. - :type context: TurnContext + :type context: TurnContext :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. :type exchange_connection: Optional[str], Optional :param exchange_scopes: Optional list of scopes to request during token exchange. If None, default scopes will be used. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py index f41fc1fa..0a5255cc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py @@ -39,7 +39,6 @@ async def validate_token(self, token: str) -> ClaimsIdentity: :rtype: ClaimsIdentity :raises ValueError: If the token is invalid or the audience does not match. """ - logger.debug("Validating JWT token.") key = await self._get_public_key_or_secret(token) decoded_token = jwt.decode( From cecf1f2ab98bf2f2cfed774915b3f6c73c132aaf Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 14 Oct 2025 14:25:44 -0700 Subject: [PATCH 05/21] Adding asyncio wrapper around msal methods --- dev/benchmark/requirements.txt | 3 +- .../authentication/msal/msal_auth.py | 70 ++++++++++++------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/dev/benchmark/requirements.txt b/dev/benchmark/requirements.txt index f8fa0cbd..ea3bd96d 100644 --- a/dev/benchmark/requirements.txt +++ b/dev/benchmark/requirements.txt @@ -1,3 +1,4 @@ microsoft-agents-activity microsoft-agents-hosting-core -click \ No newline at end of file +click +azure-identity \ No newline at end of file diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 2f0d34d9..6bbb62b9 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -3,6 +3,7 @@ from __future__ import annotations +import asyncio import logging import jwt from typing import Optional @@ -38,6 +39,10 @@ def __str__(self): agentic_blueprint_id = payload.get("xms_par_app_azp") return f"Agentic blueprint id: {agentic_blueprint_id}" +async def _async_acquire_token_for_client(msal_auth_client, *args, **kwargs): + return await asyncio.to_thread( + lambda: msal_auth_client.acquire_token_for_client(*args, **kwargs) + ) class MsalAuth(AccessTokenProviderBase): @@ -45,6 +50,7 @@ class MsalAuth(AccessTokenProviderBase): def __init__(self, msal_configuration: AgentAuthConfiguration): self._msal_configuration = msal_configuration + self._msal_auth_client = None logger.debug( f"Initializing MsalAuth with configuration: {self._msal_configuration}" ) @@ -60,16 +66,18 @@ async def get_access_token( raise ValueError("Invalid instance URL") local_scopes = self._resolve_scopes_list(instance_uri, scopes) - msal_auth_client = self._create_client_application() + self._create_client_application() - if isinstance(msal_auth_client, ManagedIdentityClient): + if isinstance(self._msal_auth_client, ManagedIdentityClient): logger.info("Acquiring token using Managed Identity Client.") - auth_result_payload = msal_auth_client.acquire_token_for_client( + auth_result_payload = await _async_acquire_token_for_client( + self._msal_auth_client, resource=resource_url ) - elif isinstance(msal_auth_client, ConfidentialClientApplication): + elif isinstance(self._msal_auth_client, ConfidentialClientApplication): logger.info("Acquiring token using Confidential Client Application.") - auth_result_payload = msal_auth_client.acquire_token_for_client( + auth_result_payload = await _async_acquire_token_for_client( + self._msal_auth_client, scopes=local_scopes ) else: @@ -91,19 +99,21 @@ async def acquire_token_on_behalf_of( :return: The access token as a string. """ - msal_auth_client = self._create_client_application() - if isinstance(msal_auth_client, ManagedIdentityClient): + self._create_client_application() + if isinstance(self._msal_auth_client, ManagedIdentityClient): logger.error( "Attempted on-behalf-of flow with Managed Identity authentication." ) raise NotImplementedError( "On-behalf-of flow is not supported with Managed Identity authentication." ) - elif isinstance(msal_auth_client, ConfidentialClientApplication): + elif isinstance(self._msal_auth_client, ConfidentialClientApplication): # TODO: Handling token error / acquisition failed - - token = msal_auth_client.acquire_token_on_behalf_of( - user_assertion=user_assertion, scopes=scopes + + token = await _async_acquire_token_for_client( + self._msal_auth_client, + scopes=scopes, + user_assertion=user_assertion, ) if "access_token" not in token: @@ -115,19 +125,21 @@ async def acquire_token_on_behalf_of( return token["access_token"] logger.error( - f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" + f"On-behalf-of flow is not supported with the current authentication type: {self._msal_auth_client.__class__.__name__}" ) raise NotImplementedError( - f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" + f"On-behalf-of flow is not supported with the current authentication type: {self._msal_auth_client.__class__.__name__}" ) def _create_client_application( self, - ) -> ManagedIdentityClient | ConfidentialClientApplication: - msal_auth_client = None + ) -> None: + + if self._msal_auth_client: + return if self._msal_configuration.AUTH_TYPE == AuthTypes.user_managed_identity: - msal_auth_client = ManagedIdentityClient( + self._msal_auth_client = ManagedIdentityClient( UserAssignedManagedIdentity( client_id=self._msal_configuration.CLIENT_ID ), @@ -135,7 +147,7 @@ def _create_client_application( ) elif self._msal_configuration.AUTH_TYPE == AuthTypes.system_managed_identity: - msal_auth_client = ManagedIdentityClient( + self._msal_auth_client = ManagedIdentityClient( SystemAssignedManagedIdentity(), http_client=Session(), ) @@ -176,14 +188,12 @@ def _create_client_application( ) raise NotImplementedError("Authentication type not supported") - msal_auth_client = ConfidentialClientApplication( + self._msal_auth_client = ConfidentialClientApplication( client_id=self._msal_configuration.CLIENT_ID, authority=authority, client_credential=self._client_credential_cache, ) - return msal_auth_client - @staticmethod def _uri_validator(url_str: str) -> tuple[bool, Optional[URI]]: try: @@ -228,14 +238,16 @@ async def get_agentic_application_token( "Attempting to get agentic application token from agent_app_instance_id %s", agent_app_instance_id, ) - msal_auth_client = self._create_client_application() + self._create_client_application() - if isinstance(msal_auth_client, ConfidentialClientApplication): + if isinstance(self._msal_auth_client, ConfidentialClientApplication): # https://github.dev/AzureAD/microsoft-authentication-library-for-dotnet - auth_result_payload = msal_auth_client.acquire_token_for_client( + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + auth_result_payload = await _async_acquire_token_for_client(self._msal_auth_client, ["api://AzureAdTokenExchange/.default"], - data={"fmi_path": agent_app_instance_id}, + data={"fmi_path": agent_app_instance_id} ) if auth_result_payload: @@ -284,7 +296,10 @@ async def get_agentic_instance_token( client_credential={"client_assertion": agent_token_result}, ) - agentic_instance_token = instance_app.acquire_token_for_client( + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + agentic_instance_token = await _async_acquire_token_for_client( + instance_app, ["api://AzureAdTokenExchange/.default"] ) @@ -363,7 +378,10 @@ async def get_agentic_user_token( agent_app_instance_id, upn, ) - auth_result_payload = instance_app.acquire_token_for_client( + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + auth_result_payload = await _async_acquire_token_for_client( + instance_app, scopes, data={ "username": upn, From a8332f5efc67f466193367480727a84ae5085961 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 15 Oct 2025 09:08:07 -0700 Subject: [PATCH 06/21] Improved benchmark code --- dev/benchmark/env.template | 3 ++ dev/benchmark/src/aggregated_results.py | 17 +++++++- dev/benchmark/src/config.py | 20 +++++++++ dev/benchmark/src/generate_token.py | 36 ++++++++++++++++ dev/benchmark/src/main.py | 30 ++++--------- dev/benchmark/src/payload_sender.py | 28 +++++++++---- dev/benchmark/src/sender.py | 56 +++++++++++++++++++++++++ 7 files changed, 158 insertions(+), 32 deletions(-) create mode 100644 dev/benchmark/env.template create mode 100644 dev/benchmark/src/config.py create mode 100644 dev/benchmark/src/generate_token.py create mode 100644 dev/benchmark/src/sender.py diff --git a/dev/benchmark/env.template b/dev/benchmark/env.template new file mode 100644 index 00000000..ea7473b2 --- /dev/null +++ b/dev/benchmark/env.template @@ -0,0 +1,3 @@ +TENANT_ID= +APP_ID= +APP_SECRET= \ No newline at end of file diff --git a/dev/benchmark/src/aggregated_results.py b/dev/benchmark/src/aggregated_results.py index 8faefcdd..f9ff1225 100644 --- a/dev/benchmark/src/aggregated_results.py +++ b/dev/benchmark/src/aggregated_results.py @@ -11,4 +11,19 @@ def __init__(self, results: list[ExecutionResult]): self.max = max((r.duration for r in results), default=0) self.success_count = sum(1 for r in results if r.success) self.failure_count = len(results) - self.success_count - self.total_time = sum(r.duration for r in results) \ No newline at end of file + self.total_time = sum(r.duration for r in results) + + def display(self, start_time: float, end_time: float): + """Display aggregated results.""" + print() + print("---- Aggregated Results ----") + print() + print(f"Average Time: {self.average:.4f} seconds") + print(f"Min Time: {self.min:.4f} seconds") + print(f"Max Time: {self.max:.4f} seconds") + print() + print(f"Success Rate: {self.success_count} / {len(self._results)}") + print() + print(f"Total Time: {end_time - start_time} seconds") + print("----------------------------") + print() \ No newline at end of file diff --git a/dev/benchmark/src/config.py b/dev/benchmark/src/config.py new file mode 100644 index 00000000..33cd87e7 --- /dev/null +++ b/dev/benchmark/src/config.py @@ -0,0 +1,20 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +class BenchmarkConfig: + """Configuration class for benchmark settings.""" + + TENANT_ID: str = "" + APP_ID: str = "" + APP_SECRET: str = "" + AGENT_API_URL: str = "" + + @classmethod + def load_from_env(cls) -> None: + """Loads configuration values from environment variables.""" + cls.TENANT_ID = os.environ.get("TENANT_ID", "") + cls.APP_ID = os.environ.get("APP_ID", "") + cls.APP_SECRET = os.environ.get("APP_SECRET", "") + cls.AGENT_URL = os.environ.get("AGENT_API_URL", "http://localhost:3978/api/messages") diff --git a/dev/benchmark/src/generate_token.py b/dev/benchmark/src/generate_token.py new file mode 100644 index 00000000..df55b5c7 --- /dev/null +++ b/dev/benchmark/src/generate_token.py @@ -0,0 +1,36 @@ +import requests +from .config import BenchmarkConfig + +# URL = "https://directline.botframework.com/v3/directline/tokens/generate" +URL = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + +def generate_token(app_id: str, app_secret: str) -> str: + """Generate a Direct Line token using the provided client ID and Direct Line secret.""" + + url = URL.format(tenant_id=BenchmarkConfig.TENANT_ID) + + res = requests.post( + url, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + data={ + "grant_type": "client_credentials", + "client_id": app_id, + "client_secret": app_secret, + "scope": f"{app_id}/.default", + }, + timeout=10 + ) + print(BenchmarkConfig.TENANT_ID) + print(res.json()) + # print(res.json().get("access_token")) + return res.json().get("access_token") + +def generate_token_from_env() -> str: + """Generates a Direct Line token using environment variables.""" + app_id = BenchmarkConfig.APP_ID + app_secret = BenchmarkConfig.APP_SECRET + if not app_id or not app_secret: + raise ValueError("APP_ID and APP_SECRET must be set in the BenchmarkConfig.") + return generate_token(app_id, app_secret) \ No newline at end of file diff --git a/dev/benchmark/src/main.py b/dev/benchmark/src/main.py index 9985c3a2..24fb21a0 100644 --- a/dev/benchmark/src/main.py +++ b/dev/benchmark/src/main.py @@ -3,13 +3,11 @@ from datetime import datetime, timezone import click -from dotenv import load_dotenv from .payload_sender import create_payload_sender from .executor import Executor, CoroutineExecutor, ThreadExecutor from .aggregated_results import AggregatedResults - -load_dotenv() +from .config import BenchmarkConfig LOG_FORMAT = "%(asctime)s: %(message)s" logging.basicConfig( @@ -18,6 +16,8 @@ datefmt="%H:%M:%S" ) +BenchmarkConfig.load_from_env() + @click.command() @click.option("--payload_path", default="./payload.json", help="Path to the payload file.") @click.option("--num-workers", default=1, help="Number of workers to use.") @@ -28,34 +28,20 @@ def main( async_mode: bool ): """Main function to run the benchmark.""" - - with open(payload_path, "r") as f: + + with open(payload_path, "r", encoding="utf-8") as f: payload = json.load(f) func = create_payload_sender(payload) executor: Executor = CoroutineExecutor() if async_mode else ThreadExecutor() - start_time = datetime.now(timezone.utc) + start_time = datetime.now(timezone.utc).timestamp() results = executor.run(func, num_workers=num_workers) - end_time = datetime.now(timezone.utc) + end_time = datetime.now(timezone.utc).timestamp() agg = AggregatedResults(results) - - print() - print("---- Aggregated Results ----") - print() - print(f"Average Time: {agg.average:.4f} seconds") - print(f"Min Time: {agg.min:.4f} seconds") - print(f"Max Time: {agg.max:.4f} seconds") - print() - print(f"Success Rate: {agg.success_count} / {len(results)}") - print() - print(f"Total Time: {end_time - start_time} seconds") - print("----------------------------") - print() - - print(results[0]) + agg.display(start_time, end_time) if __name__ == "__main__": main() # pylint: disable=no-value-for-parameter \ No newline at end of file diff --git a/dev/benchmark/src/payload_sender.py b/dev/benchmark/src/payload_sender.py index 28a90555..0630784d 100644 --- a/dev/benchmark/src/payload_sender.py +++ b/dev/benchmark/src/payload_sender.py @@ -2,26 +2,36 @@ # Licensed under the MIT License. import os -import json import requests from typing import Callable, Awaitable, Any -def create_payload_sender(payload: dict[str, Any]) -> Callable[..., Awaitable[None]]: +from .config import BenchmarkConfig +from .generate_token import generate_token_from_env - JWT_TOKEN = os.environ.get("JWT_TOKEN") - ENDPOINT = "http://localhost:3978/api/messages" - HEADERS = { - "Authorization": f"Bearer {JWT_TOKEN}", +def create_payload_sender( + payload: dict[str, Any], + timeout: int = 60 + ) -> Callable[..., Awaitable[None]]: + + token = generate_token_from_env() + + endpoint = BenchmarkConfig.AGENT_URL + + headers = { + "Authorization": f"Bearer {token}", "Content-Type": "application/json" } async def payload_sender() -> Any: + print(headers) response = requests.post( - ENDPOINT, - data=payload, - headers=HEADERS + endpoint, + headers=headers, + json=payload, + timeout=timeout ) + print(response.content) return response.content return payload_sender \ No newline at end of file diff --git a/dev/benchmark/src/sender.py b/dev/benchmark/src/sender.py new file mode 100644 index 00000000..d2127fca --- /dev/null +++ b/dev/benchmark/src/sender.py @@ -0,0 +1,56 @@ +# python >=3.10 +import os, time, json, concurrent.futures, requests, random, string + +DIRECT_LINE_SECRET = os.environ["DIRECT_LINE_SECRET"] # from Direct Line channel +BASE = "https://directline.botframework.com/v3/directline" + +def gen_token(): + r = requests.post( + f"{BASE}/tokens/generate", + headers={"Authorization": f"Bearer {DIRECT_LINE_SECRET}"}, + json={"user": {"id": "user-" + ''.join(random.choices(string.ascii_lowercase, k=6))}} + ) + r.raise_for_status() + return r.json()["token"] + +def start_conversation(token): + r = requests.post( + f"{BASE}/conversations", + headers={"Authorization": f"Bearer {token}"} + ) + r.raise_for_status() + return r.json()["conversationId"] + +def post_message(token, conv_id, text): + payload = { + "type": "message", + "from": {"id": "loader"}, + "text": text, + "locale": "en-US" + } + r = requests.post( + f"{BASE}/conversations/{conv_id}/activities", + headers={"Authorization": f"Bearer {token}"}, + json=payload, + timeout=30 + ) + r.raise_for_status() + return r.json() + +def worker(_): + token = gen_token() + conv = start_conversation(token) + results = [] + for i in range(25): # messages per conversation + t0 = time.perf_counter() + post_message(token, conv, f"ping {i}") + t1 = time.perf_counter() + results.append(t1 - t0) + return results +if __name__ == "__main__": + USERS = 50 # concurrent conversations + with concurrent.futures.ThreadPoolExecutor(max_workers=USERS) as ex: + batches = list(ex.map(worker, range(USERS))) + latencies = [x for b in batches for x in b] + print(f"sent={len(latencies)}, p50={sorted(latencies)[len(latencies)//2]:.3f}s, " + f"max={max(latencies):.3f}s") \ No newline at end of file From aa99aa75521ef1dfff8dbeca5e7299dc5726bf2f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 15 Oct 2025 09:08:50 -0700 Subject: [PATCH 07/21] Another commit --- .../microsoft_agents/authentication/msal/msal_auth.py | 6 +++--- .../microsoft_agents/hosting/aiohttp/cloud_adapter.py | 1 + .../hosting/aiohttp/jwt_authorization_middleware.py | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 6bbb62b9..fd6c442c 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -87,6 +87,7 @@ async def get_access_token( if not res: logger.error("Failed to acquire token for resource %s", auth_result_payload) raise ValueError(f"Failed to acquire token. {str(auth_result_payload)}") + return res async def acquire_token_on_behalf_of( @@ -131,10 +132,9 @@ async def acquire_token_on_behalf_of( f"On-behalf-of flow is not supported with the current authentication type: {self._msal_auth_client.__class__.__name__}" ) - def _create_client_application( - self, - ) -> None: + def _create_client_application(self) -> None: + breakpoint() if self._msal_auth_client: return diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py index 1ef106c3..acf5a158 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py @@ -81,6 +81,7 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: else: raise HTTPUnsupportedMediaType() + breakpoint() activity: Activity = Activity.model_validate(body) # default to anonymous identity with no claims diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py index a30a95e5..7435890e 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py @@ -13,6 +13,7 @@ async def jwt_authorization_middleware(request: Request, handler): auth_config: AgentAuthConfiguration = request.app["agent_configuration"] token_validator = JwtTokenValidator(auth_config) auth_header = request.headers.get("Authorization") + breakpoint() if auth_header: # Extract the token from the Authorization header token = auth_header.split(" ")[1] From c3ec34607d4c7a15c752705a90f4ee8e7e1b9d60 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 15 Oct 2025 12:00:53 -0700 Subject: [PATCH 08/21] Removing breakpoints --- .../microsoft_agents/authentication/msal/msal_auth.py | 1 - .../microsoft_agents/hosting/aiohttp/cloud_adapter.py | 1 - .../hosting/aiohttp/jwt_authorization_middleware.py | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index fd6c442c..acaab920 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -134,7 +134,6 @@ async def acquire_token_on_behalf_of( def _create_client_application(self) -> None: - breakpoint() if self._msal_auth_client: return diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py index acf5a158..1ef106c3 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/cloud_adapter.py @@ -81,7 +81,6 @@ async def process(self, request: Request, agent: Agent) -> Optional[Response]: else: raise HTTPUnsupportedMediaType() - breakpoint() activity: Activity = Activity.model_validate(body) # default to anonymous identity with no claims diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py index 7435890e..d3a2384d 100644 --- a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py @@ -13,7 +13,7 @@ async def jwt_authorization_middleware(request: Request, handler): auth_config: AgentAuthConfiguration = request.app["agent_configuration"] token_validator = JwtTokenValidator(auth_config) auth_header = request.headers.get("Authorization") - breakpoint() + if auth_header: # Extract the token from the Authorization header token = auth_header.split(" ")[1] From 399eb92c1fc95177cbd594618d40b5142f8c4563 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 15 Oct 2025 13:54:27 -0700 Subject: [PATCH 09/21] Using expect_replies in payload --- dev/benchmark/payload.json | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/dev/benchmark/payload.json b/dev/benchmark/payload.json index 352241e0..28ac2a8c 100644 --- a/dev/benchmark/payload.json +++ b/dev/benchmark/payload.json @@ -1,4 +1,19 @@ { - "type": "message", - "text": "Hello world!" + "channelId": "msteams", + "serviceUrl": "http://localhost:49231/_connector", + "delivery_mode": "expectReplies", + "recipient": { + "id": "00000000-0000-0000-0000-00000000000011", + "name": "Test Bot" + }, + "conversation": { + "id": "personal-chat-id", + "conversationType": "personal", + "tenantId": "00000000-0000-0000-0000-0000000000001" + }, + "from": { + "id": "user-id-0", + "aadObjectId": "00000000-0000-0000-0000-0000000000020" + }, + "type": "message" } \ No newline at end of file From 9f018190dc2b232ee664cb9868bd7a0146a76e5b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 15 Oct 2025 13:59:01 -0700 Subject: [PATCH 10/21] Updating README.md --- dev/benchmark/README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/dev/benchmark/README.md b/dev/benchmark/README.md index e24a8d62..93ad2577 100644 --- a/dev/benchmark/README.md +++ b/dev/benchmark/README.md @@ -1,6 +1,6 @@ A simple benchmarking tool. -## Benchmark Setup (Windows) +## Benchmark Python Environment Setup (Windows) Traditionally, most Python versions have a global interpreter lock (GIL) which prevents more than 1 thread to run at the same time. With 3.13, there are free-threaded versions @@ -42,6 +42,23 @@ To deactivate it, you may use: deactivate ``` +## Benchmark Configuration + +If you open the `env.template` file, you will see three environmental variables to define: + +```bash +TENANT_ID= +APP_ID= +APP_SECRET= +``` + +For `APP_ID` use the app Id of your ABS resource. For `APP_SECRET` set it to a secret +for the App Registration resource tied to your ABS resource. Finally, the `TENANT_ID` +variable should be set to the tenant Id of your ABS resource. + +These settings are used to generate valid tokens that are sent and validated by the +agent you are trying to run. + ## Complete Setup Running these tests requires you to have the agent running in a separate process. You From 80691e067b0fc2704721233878d75251e30d6494 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 15 Oct 2025 14:01:20 -0700 Subject: [PATCH 11/21] Removing unused file --- dev/benchmark/src/payload_sender.py | 3 -- dev/benchmark/src/sender.py | 56 ----------------------------- 2 files changed, 59 deletions(-) delete mode 100644 dev/benchmark/src/sender.py diff --git a/dev/benchmark/src/payload_sender.py b/dev/benchmark/src/payload_sender.py index 0630784d..e101d1a4 100644 --- a/dev/benchmark/src/payload_sender.py +++ b/dev/benchmark/src/payload_sender.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import os import requests from typing import Callable, Awaitable, Any @@ -24,14 +23,12 @@ def create_payload_sender( async def payload_sender() -> Any: - print(headers) response = requests.post( endpoint, headers=headers, json=payload, timeout=timeout ) - print(response.content) return response.content return payload_sender \ No newline at end of file diff --git a/dev/benchmark/src/sender.py b/dev/benchmark/src/sender.py deleted file mode 100644 index d2127fca..00000000 --- a/dev/benchmark/src/sender.py +++ /dev/null @@ -1,56 +0,0 @@ -# python >=3.10 -import os, time, json, concurrent.futures, requests, random, string - -DIRECT_LINE_SECRET = os.environ["DIRECT_LINE_SECRET"] # from Direct Line channel -BASE = "https://directline.botframework.com/v3/directline" - -def gen_token(): - r = requests.post( - f"{BASE}/tokens/generate", - headers={"Authorization": f"Bearer {DIRECT_LINE_SECRET}"}, - json={"user": {"id": "user-" + ''.join(random.choices(string.ascii_lowercase, k=6))}} - ) - r.raise_for_status() - return r.json()["token"] - -def start_conversation(token): - r = requests.post( - f"{BASE}/conversations", - headers={"Authorization": f"Bearer {token}"} - ) - r.raise_for_status() - return r.json()["conversationId"] - -def post_message(token, conv_id, text): - payload = { - "type": "message", - "from": {"id": "loader"}, - "text": text, - "locale": "en-US" - } - r = requests.post( - f"{BASE}/conversations/{conv_id}/activities", - headers={"Authorization": f"Bearer {token}"}, - json=payload, - timeout=30 - ) - r.raise_for_status() - return r.json() - -def worker(_): - token = gen_token() - conv = start_conversation(token) - results = [] - for i in range(25): # messages per conversation - t0 = time.perf_counter() - post_message(token, conv, f"ping {i}") - t1 = time.perf_counter() - results.append(t1 - t0) - return results -if __name__ == "__main__": - USERS = 50 # concurrent conversations - with concurrent.futures.ThreadPoolExecutor(max_workers=USERS) as ex: - batches = list(ex.map(worker, range(USERS))) - latencies = [x for b in batches for x in b] - print(f"sent={len(latencies)}, p50={sorted(latencies)[len(latencies)//2]:.3f}s, " - f"max={max(latencies):.3f}s") \ No newline at end of file From da4dd8d68325ddbdaa0e28d3d3215c7413883b21 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 15 Oct 2025 14:09:11 -0700 Subject: [PATCH 12/21] Changes to async executor --- dev/benchmark/src/executor/coroutine_executor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev/benchmark/src/executor/coroutine_executor.py b/dev/benchmark/src/executor/coroutine_executor.py index b4b9d8fb..083aa727 100644 --- a/dev/benchmark/src/executor/coroutine_executor.py +++ b/dev/benchmark/src/executor/coroutine_executor.py @@ -16,4 +16,6 @@ def run(self, func: Callable[[], Awaitable[Any]], num_workers: int = 1) -> list[ :param func: An asynchronous function to be executed. :param num_workers: The number of concurrent threads to use. """ - return asyncio.run(asyncio.gather(*(self.run_func(i, func) for i in range(num_workers)))) \ No newline at end of file + return asyncio.run(asyncio.gather( + *[self.run_func(i, func) for i in range(num_workers)] + )) \ No newline at end of file From e6e489bee5fbb15369f5831dd956952a042357fe Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Oct 2025 05:46:28 -0700 Subject: [PATCH 13/21] Small tweak to driver --- dev/benchmark/src/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/benchmark/src/main.py b/dev/benchmark/src/main.py index 24fb21a0..868c2b1f 100644 --- a/dev/benchmark/src/main.py +++ b/dev/benchmark/src/main.py @@ -20,7 +20,7 @@ @click.command() @click.option("--payload_path", default="./payload.json", help="Path to the payload file.") -@click.option("--num-workers", default=1, help="Number of workers to use.") +@click.option("--num_workers", default=1, help="Number of workers to use.") @click.option("--async_mode", is_flag=True, help="Run coroutine workers rather than thread workers.") def main( payload_path: str, From 5adbb0cebc86d87d37a0f2e0a3032126fbab3019 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Oct 2025 06:01:38 -0700 Subject: [PATCH 14/21] Fixing incorrect call replacement --- dev/benchmark/src/aggregated_results.py | 3 +- dev/benchmark/src/config.py | 7 +++- dev/benchmark/src/executor/__init__.py | 2 +- .../src/executor/coroutine_executor.py | 13 ++++--- .../src/executor/execution_result.py | 3 +- dev/benchmark/src/executor/executor.py | 23 +++++++----- dev/benchmark/src/executor/thread_executor.py | 11 ++++-- dev/benchmark/src/generate_token.py | 6 ++- dev/benchmark/src/main.py | 26 ++++++------- dev/benchmark/src/payload_sender.py | 18 +++------ .../authentication/msal/msal_auth.py | 37 ++++++++++--------- .../_handlers/agentic_user_authorization.py | 2 +- .../core/authorization/jwt_token_validator.py | 25 ++++++------- .../testing_objects/mocks/mock_msal_auth.py | 3 +- tests/authentication_msal/test_msal_auth.py | 3 -- 15 files changed, 96 insertions(+), 86 deletions(-) diff --git a/dev/benchmark/src/aggregated_results.py b/dev/benchmark/src/aggregated_results.py index f9ff1225..1e346310 100644 --- a/dev/benchmark/src/aggregated_results.py +++ b/dev/benchmark/src/aggregated_results.py @@ -1,5 +1,6 @@ from .executor import ExecutionResult + class AggregatedResults: """Class to analyze execution time results.""" @@ -26,4 +27,4 @@ def display(self, start_time: float, end_time: float): print() print(f"Total Time: {end_time - start_time} seconds") print("----------------------------") - print() \ No newline at end of file + print() diff --git a/dev/benchmark/src/config.py b/dev/benchmark/src/config.py index 33cd87e7..403fbafc 100644 --- a/dev/benchmark/src/config.py +++ b/dev/benchmark/src/config.py @@ -3,9 +3,10 @@ load_dotenv() + class BenchmarkConfig: """Configuration class for benchmark settings.""" - + TENANT_ID: str = "" APP_ID: str = "" APP_SECRET: str = "" @@ -17,4 +18,6 @@ def load_from_env(cls) -> None: cls.TENANT_ID = os.environ.get("TENANT_ID", "") cls.APP_ID = os.environ.get("APP_ID", "") cls.APP_SECRET = os.environ.get("APP_SECRET", "") - cls.AGENT_URL = os.environ.get("AGENT_API_URL", "http://localhost:3978/api/messages") + cls.AGENT_URL = os.environ.get( + "AGENT_API_URL", "http://localhost:3978/api/messages" + ) diff --git a/dev/benchmark/src/executor/__init__.py b/dev/benchmark/src/executor/__init__.py index 88da0e48..b01cfb1c 100644 --- a/dev/benchmark/src/executor/__init__.py +++ b/dev/benchmark/src/executor/__init__.py @@ -8,4 +8,4 @@ "ExecutionResult", "Executor", "ThreadExecutor", -] \ No newline at end of file +] diff --git a/dev/benchmark/src/executor/coroutine_executor.py b/dev/benchmark/src/executor/coroutine_executor.py index 083aa727..99245497 100644 --- a/dev/benchmark/src/executor/coroutine_executor.py +++ b/dev/benchmark/src/executor/coroutine_executor.py @@ -7,15 +7,18 @@ from .executor import Executor from .execution_result import ExecutionResult + class CoroutineExecutor(Executor): """An executor that runs asynchronous functions using asyncio.""" - def run(self, func: Callable[[], Awaitable[Any]], num_workers: int = 1) -> list[ExecutionResult]: + def run( + self, func: Callable[[], Awaitable[Any]], num_workers: int = 1 + ) -> list[ExecutionResult]: """Run the given asynchronous function using the specified number of threads. - + :param func: An asynchronous function to be executed. :param num_workers: The number of concurrent threads to use. """ - return asyncio.run(asyncio.gather( - *[self.run_func(i, func) for i in range(num_workers)] - )) \ No newline at end of file + return asyncio.run( + asyncio.gather(*[self.run_func(i, func) for i in range(num_workers)]) + ) diff --git a/dev/benchmark/src/executor/execution_result.py b/dev/benchmark/src/executor/execution_result.py index 8a1583c2..ae72cabb 100644 --- a/dev/benchmark/src/executor/execution_result.py +++ b/dev/benchmark/src/executor/execution_result.py @@ -4,6 +4,7 @@ from typing import Any, Optional from dataclasses import dataclass + @dataclass class ExecutionResult: """Class to represent the result of an execution.""" @@ -24,4 +25,4 @@ def success(self) -> bool: @property def duration(self) -> float: """Calculate the duration of the execution, in seconds.""" - return self.end_time - self.start_time \ No newline at end of file + return self.end_time - self.start_time diff --git a/dev/benchmark/src/executor/executor.py b/dev/benchmark/src/executor/executor.py index 9ed40d09..657f2549 100644 --- a/dev/benchmark/src/executor/executor.py +++ b/dev/benchmark/src/executor/executor.py @@ -7,16 +7,19 @@ from .execution_result import ExecutionResult + class Executor(ABC): """Protocol for executing asynchronous functions concurrently.""" - async def run_func(self, exe_id: int, func: Callable[[], Awaitable[Any]]) -> ExecutionResult: + async def run_func( + self, exe_id: int, func: Callable[[], Awaitable[Any]] + ) -> ExecutionResult: """Run the given asynchronous function. - + :param exe_id: An identifier for the execution instance. :param func: An asynchronous function to be executed. """ - + start_time = datetime.now(timezone.utc).timestamp() try: result = await func() @@ -24,21 +27,23 @@ async def run_func(self, exe_id: int, func: Callable[[], Awaitable[Any]]) -> Exe exe_id=exe_id, result=result, start_time=start_time, - end_time=datetime.now(timezone.utc).timestamp() + end_time=datetime.now(timezone.utc).timestamp(), ) - except Exception as e: # pylint: disable=broad-except + except Exception as e: # pylint: disable=broad-except return ExecutionResult( exe_id=exe_id, error=e, start_time=start_time, - end_time=datetime.now(timezone.utc).timestamp() + end_time=datetime.now(timezone.utc).timestamp(), ) @abstractmethod - def run(self, func: Callable[[], Awaitable[None]], num_workers: int = 1) -> list[ExecutionResult]: + def run( + self, func: Callable[[], Awaitable[None]], num_workers: int = 1 + ) -> list[ExecutionResult]: """Run the given asynchronous function using the specified number of workers. - + :param func: An asynchronous function to be executed. :param num_workers: The number of concurrent workers to use. """ - raise NotImplementedError("This method should be implemented by subclasses.") \ No newline at end of file + raise NotImplementedError("This method should be implemented by subclasses.") diff --git a/dev/benchmark/src/executor/thread_executor.py b/dev/benchmark/src/executor/thread_executor.py index 4e119be9..c11bd2b8 100644 --- a/dev/benchmark/src/executor/thread_executor.py +++ b/dev/benchmark/src/executor/thread_executor.py @@ -11,19 +11,22 @@ logger = logging.getLogger(__name__) + class ThreadExecutor(Executor): """An executor that runs asynchronous functions using multiple threads.""" - def run(self, func: Callable[[], Awaitable[Any]], num_workers: int = 1) -> list[ExecutionResult]: + def run( + self, func: Callable[[], Awaitable[Any]], num_workers: int = 1 + ) -> list[ExecutionResult]: """Run the given asynchronous function using the specified number of threads. - + :param func: An asynchronous function to be executed. :param num_workers: The number of concurrent threads to use. """ def _func(exe_id: int) -> ExecutionResult: return asyncio.run(self.run_func(exe_id, func)) - + results: list[ExecutionResult] = [] with ThreadPoolExecutor(max_workers=num_workers) as executor: @@ -31,4 +34,4 @@ def _func(exe_id: int) -> ExecutionResult: for future in futures: results.append(future.result()) - return results \ No newline at end of file + return results diff --git a/dev/benchmark/src/generate_token.py b/dev/benchmark/src/generate_token.py index df55b5c7..fac8fbd2 100644 --- a/dev/benchmark/src/generate_token.py +++ b/dev/benchmark/src/generate_token.py @@ -4,6 +4,7 @@ # URL = "https://directline.botframework.com/v3/directline/tokens/generate" URL = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + def generate_token(app_id: str, app_secret: str) -> str: """Generate a Direct Line token using the provided client ID and Direct Line secret.""" @@ -20,17 +21,18 @@ def generate_token(app_id: str, app_secret: str) -> str: "client_secret": app_secret, "scope": f"{app_id}/.default", }, - timeout=10 + timeout=10, ) print(BenchmarkConfig.TENANT_ID) print(res.json()) # print(res.json().get("access_token")) return res.json().get("access_token") + def generate_token_from_env() -> str: """Generates a Direct Line token using environment variables.""" app_id = BenchmarkConfig.APP_ID app_secret = BenchmarkConfig.APP_SECRET if not app_id or not app_secret: raise ValueError("APP_ID and APP_SECRET must be set in the BenchmarkConfig.") - return generate_token(app_id, app_secret) \ No newline at end of file + return generate_token(app_id, app_secret) diff --git a/dev/benchmark/src/main.py b/dev/benchmark/src/main.py index 868c2b1f..98c26967 100644 --- a/dev/benchmark/src/main.py +++ b/dev/benchmark/src/main.py @@ -10,23 +10,22 @@ from .config import BenchmarkConfig LOG_FORMAT = "%(asctime)s: %(message)s" -logging.basicConfig( - format=LOG_FORMAT, - level=logging.INFO, - datefmt="%H:%M:%S" -) +logging.basicConfig(format=LOG_FORMAT, level=logging.INFO, datefmt="%H:%M:%S") BenchmarkConfig.load_from_env() + @click.command() -@click.option("--payload_path", default="./payload.json", help="Path to the payload file.") +@click.option( + "--payload_path", default="./payload.json", help="Path to the payload file." +) @click.option("--num_workers", default=1, help="Number of workers to use.") -@click.option("--async_mode", is_flag=True, help="Run coroutine workers rather than thread workers.") -def main( - payload_path: str, - num_workers: int, - async_mode: bool -): +@click.option( + "--async_mode", + is_flag=True, + help="Run coroutine workers rather than thread workers.", +) +def main(payload_path: str, num_workers: int, async_mode: bool): """Main function to run the benchmark.""" with open(payload_path, "r", encoding="utf-8") as f: @@ -43,5 +42,6 @@ def main( agg = AggregatedResults(results) agg.display(start_time, end_time) + if __name__ == "__main__": - main() # pylint: disable=no-value-for-parameter \ No newline at end of file + main() # pylint: disable=no-value-for-parameter diff --git a/dev/benchmark/src/payload_sender.py b/dev/benchmark/src/payload_sender.py index e101d1a4..33abd2b4 100644 --- a/dev/benchmark/src/payload_sender.py +++ b/dev/benchmark/src/payload_sender.py @@ -7,28 +7,22 @@ from .config import BenchmarkConfig from .generate_token import generate_token_from_env + def create_payload_sender( - payload: dict[str, Any], - timeout: int = 60 - ) -> Callable[..., Awaitable[None]]: + payload: dict[str, Any], timeout: int = 60 +) -> Callable[..., Awaitable[None]]: token = generate_token_from_env() endpoint = BenchmarkConfig.AGENT_URL - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json" - } + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async def payload_sender() -> Any: response = requests.post( - endpoint, - headers=headers, - json=payload, - timeout=timeout + endpoint, headers=headers, json=payload, timeout=timeout ) return response.content - return payload_sender \ No newline at end of file + return payload_sender diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index acaab920..55e6cd03 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -39,11 +39,16 @@ def __str__(self): agentic_blueprint_id = payload.get("xms_par_app_azp") return f"Agentic blueprint id: {agentic_blueprint_id}" + async def _async_acquire_token_for_client(msal_auth_client, *args, **kwargs): + """MSAL in Python does not support async, so we use asyncio.to_thread to run it in + a separate thread and avoid blocking the event loop + """ return await asyncio.to_thread( lambda: msal_auth_client.acquire_token_for_client(*args, **kwargs) ) + class MsalAuth(AccessTokenProviderBase): _client_credential_cache = None @@ -71,14 +76,12 @@ async def get_access_token( if isinstance(self._msal_auth_client, ManagedIdentityClient): logger.info("Acquiring token using Managed Identity Client.") auth_result_payload = await _async_acquire_token_for_client( - self._msal_auth_client, - resource=resource_url + self._msal_auth_client, resource=resource_url ) elif isinstance(self._msal_auth_client, ConfidentialClientApplication): logger.info("Acquiring token using Confidential Client Application.") auth_result_payload = await _async_acquire_token_for_client( - self._msal_auth_client, - scopes=local_scopes + self._msal_auth_client, scopes=local_scopes ) else: auth_result_payload = None @@ -110,11 +113,13 @@ async def acquire_token_on_behalf_of( ) elif isinstance(self._msal_auth_client, ConfidentialClientApplication): # TODO: Handling token error / acquisition failed - - token = await _async_acquire_token_for_client( - self._msal_auth_client, - scopes=scopes, - user_assertion=user_assertion, + + # MSAL in Python does not support async, so we use asyncio.to_thread to run it in + # a separate thread and avoid blocking the event loop + token = await asyncio.to_thread( + lambda: self._msal_auth_client.acquire_token_on_behalf_of( + scopes=scopes, user_assertion=user_assertion + ) ) if "access_token" not in token: @@ -133,7 +138,7 @@ async def acquire_token_on_behalf_of( ) def _create_client_application(self) -> None: - + if self._msal_auth_client: return @@ -242,11 +247,10 @@ async def get_agentic_application_token( if isinstance(self._msal_auth_client, ConfidentialClientApplication): # https://github.dev/AzureAD/microsoft-authentication-library-for-dotnet - # MSAL in Python does not support async, so we use asyncio.to_thread to run it in - # a separate thread and avoid blocking the event loop - auth_result_payload = await _async_acquire_token_for_client(self._msal_auth_client, + auth_result_payload = await _async_acquire_token_for_client( + self._msal_auth_client, ["api://AzureAdTokenExchange/.default"], - data={"fmi_path": agent_app_instance_id} + data={"fmi_path": agent_app_instance_id}, ) if auth_result_payload: @@ -295,11 +299,8 @@ async def get_agentic_instance_token( client_credential={"client_assertion": agent_token_result}, ) - # MSAL in Python does not support async, so we use asyncio.to_thread to run it in - # a separate thread and avoid blocking the event loop agentic_instance_token = await _async_acquire_token_for_client( - instance_app, - ["api://AzureAdTokenExchange/.default"] + instance_app, ["api://AzureAdTokenExchange/.default"] ) if not agentic_instance_token: diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py index 9bb9066e..7c8a5a30 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/agentic_user_authorization.py @@ -170,7 +170,7 @@ async def get_refreshed_token( """Attempts to get a refreshed token for the user with the given scopes :param context: The turn context for the current turn of conversation. - :type context: TurnContext + :type context: TurnContext :param exchange_connection: Optional name of the connection to use for token exchange. If None, default connection will be used. :type exchange_connection: Optional[str], Optional :param exchange_scopes: Optional list of scopes to request during token exchange. If None, default scopes will be used. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py index 0a5255cc..266fff50 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py @@ -18,14 +18,14 @@ class JwtTokenValidator: def __init__(self, configuration: AgentAuthConfiguration): """Initialize the JwtTokenValidator with the given configuration. - + :param configuration: The AgentAuthConfiguration instance containing settings. :type configuration: AgentAuthConfiguration :raises ValueError: If configuration is None. """ if not configuration: raise ValueError("Configuration cannot be None.") - + self.configuration = configuration self._default_jwks_client = None self._tenant_jwks_client = None @@ -60,10 +60,10 @@ def get_anonymous_claims(self) -> ClaimsIdentity: """Return an anonymous ClaimsIdentity.""" logger.debug("Returning anonymous claims identity.") return ClaimsIdentity({}, False, authentication_type="Anonymous") - + def _get_client(self, issuer: str) -> PyJWKClient: """Get the appropriate JWKS client based on the issuer. - + :param issuer: The issuer URL from the token. :type issuer: str :return: The corresponding PyJWKClient instance. @@ -78,34 +78,32 @@ def _get_client(self, issuer: str) -> PyJWKClient: if not client: raise RuntimeError("JWKS client is not initialized.") return client - + def _init_jwks_client(self, issuer: str) -> None: """Initialize the JWKS client based on the issuer. - + :param issuer: The issuer URL from the token. :type issuer: str """ - client_options = { - "cache_keys": True - } + client_options = {"cache_keys": True} if issuer == "https://api.botframework.com": if self._default_jwks_client is None: self._default_jwks_client = PyJWKClient( "https://login.botframework.com/v1/.well-known/keys", - **client_options + **client_options, ) else: if self._tenant_jwks_client is None: self._tenant_jwks_client = PyJWKClient( f"https://login.microsoftonline.com/{self.configuration.TENANT_ID}/discovery/v2.0/keys", - **client_options + **client_options, ) async def _get_public_key_or_secret(self, token: str) -> PyJWK: """Extract the public key or secret from the JWT token. - + :param token: The JWT token. :type token: str :return: The public key or secret used to verify the token. @@ -120,9 +118,10 @@ async def _get_public_key_or_secret(self, token: str) -> PyJWK: if not issuer: raise ValueError("Issuer (iss) claim is missing in the token.") self._init_jwks_client(issuer) - + def func(): return self._get_client(issuer).get_signing_key(header["kid"]) + key = await asyncio.to_thread(func) return key diff --git a/tests/_common/testing_objects/mocks/mock_msal_auth.py b/tests/_common/testing_objects/mocks/mock_msal_auth.py index f9a046b7..c9e9eb09 100644 --- a/tests/_common/testing_objects/mocks/mock_msal_auth.py +++ b/tests/_common/testing_objects/mocks/mock_msal_auth.py @@ -25,7 +25,8 @@ def __init__( ) self.mock_client = mock_client - self._create_client_application = mocker.Mock(return_value=self.mock_client) + def _create_client_application(self) -> None: + self._msal_auth_client = self.mock_client def agentic_mock_class_MsalAuth( diff --git a/tests/authentication_msal/test_msal_auth.py b/tests/authentication_msal/test_msal_auth.py index 7198d190..4368c63e 100644 --- a/tests/authentication_msal/test_msal_auth.py +++ b/tests/authentication_msal/test_msal_auth.py @@ -52,9 +52,6 @@ async def test_acquire_token_on_behalf_of_managed_identity(self, mocker): @pytest.mark.asyncio async def test_acquire_token_on_behalf_of_confidential(self, mocker): mock_auth = MockMsalAuth(mocker, ConfidentialClientApplication) - mock_auth._create_client_application = mocker.Mock( - return_value=mock_auth.mock_client - ) token = await mock_auth.acquire_token_on_behalf_of( scopes=["test-scope"], user_assertion="test-assertion" From 855462adeff27dfa2cf5a97706341da640263592 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Oct 2025 06:08:41 -0700 Subject: [PATCH 15/21] Commenting out unfinished Coroutine executor definition --- dev/README.md | 2 +- dev/benchmark/src/executor/coroutine_executor.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/dev/README.md b/dev/README.md index 966b863e..e8c10764 100644 --- a/dev/README.md +++ b/dev/README.md @@ -2,5 +2,5 @@ This directory contains tools to aid the developers of the Microsoft 365 Agents ### `benchmark` -This folder contains benchmarking utilities built in Python to send concurrent threads +This folder contains benchmarking utilities built in Python to send concurrent requests to an agent. \ No newline at end of file diff --git a/dev/benchmark/src/executor/coroutine_executor.py b/dev/benchmark/src/executor/coroutine_executor.py index 99245497..60b4adac 100644 --- a/dev/benchmark/src/executor/coroutine_executor.py +++ b/dev/benchmark/src/executor/coroutine_executor.py @@ -14,11 +14,12 @@ class CoroutineExecutor(Executor): def run( self, func: Callable[[], Awaitable[Any]], num_workers: int = 1 ) -> list[ExecutionResult]: - """Run the given asynchronous function using the specified number of threads. + # """Run the given asynchronous function using the specified number of threads. - :param func: An asynchronous function to be executed. - :param num_workers: The number of concurrent threads to use. - """ - return asyncio.run( - asyncio.gather(*[self.run_func(i, func) for i in range(num_workers)]) - ) + # :param func: An asynchronous function to be executed. + # :param num_workers: The number of concurrent threads to use. + # """ + # return asyncio.run( + # asyncio.gather(*[self.run_func(i, func) for i in range(num_workers)]) + # ) + raise NotImplementedError("CoroutineExecutor.run is not implemented yet.") From 866772f4aa38a8a2552bfd7c619158c632f43716 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Oct 2025 06:37:48 -0700 Subject: [PATCH 16/21] Addressing copilot review comments --- dev/benchmark/README.md | 40 ++++++++++++++++++- .../src/executor/coroutine_executor.py | 4 +- dev/benchmark/src/executor/executor.py | 2 +- dev/benchmark/src/executor/thread_executor.py | 2 +- dev/benchmark/src/payload_sender.py | 16 +++++--- .../authentication/msal/msal_auth.py | 7 ++++ .../core/authorization/jwt_token_validator.py | 7 ++-- 7 files changed, 62 insertions(+), 16 deletions(-) diff --git a/dev/benchmark/README.md b/dev/benchmark/README.md index 93ad2577..78ff2067 100644 --- a/dev/benchmark/README.md +++ b/dev/benchmark/README.md @@ -2,6 +2,35 @@ A simple benchmarking tool. ## Benchmark Python Environment Setup (Windows) +Currently a version of this tool that spawns async workers/coroutines instead of +concurrent threads is not supported, so if you use a "normal" (non free-threaded) version +of Python, you will be running with the global interpreter lock (GIL). + +Note: This may or may not incur significant changes in performance over using +free-threaded concurrent tests or async workers, depending on the test scenario. + +Install any Python version >= 3.9. Then, set up and activate the virtual environment with: + +```bash +python -m venv venv +. ./venv/Scripts/activate +pip install -r requirements.txt +``` + +To activate the virtual environment, use: + +```bash +. ./venv/Scripts/activate +``` + +To deactivate it, you may use: + +```bash +deactivate +``` + +## Benchmark Python Environment Setup (Windows) - Free Threaded Python + Traditionally, most Python versions have a global interpreter lock (GIL) which prevents more than 1 thread to run at the same time. With 3.13, there are free-threaded versions of Python which allow one to bypass this constraint. This section walks through how @@ -59,7 +88,14 @@ variable should be set to the tenant Id of your ABS resource. These settings are used to generate valid tokens that are sent and validated by the agent you are trying to run. -## Complete Setup +## Usage Running these tests requires you to have the agent running in a separate process. You -may open a separate PowerShell window or VSCode window and run your agent there. \ No newline at end of file +may open a separate PowerShell window or VSCode window and run your agent there. + +To run the basic payload sending stress test (our only implemented test so far), use: + +```bash +. ./venv/Scripts/activate # activate the virtual environment if you haven't already +python -m src.main --num_workers=... +``` diff --git a/dev/benchmark/src/executor/coroutine_executor.py b/dev/benchmark/src/executor/coroutine_executor.py index 60b4adac..2f5ed45f 100644 --- a/dev/benchmark/src/executor/coroutine_executor.py +++ b/dev/benchmark/src/executor/coroutine_executor.py @@ -14,10 +14,10 @@ class CoroutineExecutor(Executor): def run( self, func: Callable[[], Awaitable[Any]], num_workers: int = 1 ) -> list[ExecutionResult]: - # """Run the given asynchronous function using the specified number of threads. + # """Run the given asynchronous function using the specified number of coroutines. # :param func: An asynchronous function to be executed. - # :param num_workers: The number of concurrent threads to use. + # :param num_workers: The number of coroutines to use. # """ # return asyncio.run( # asyncio.gather(*[self.run_func(i, func) for i in range(num_workers)]) diff --git a/dev/benchmark/src/executor/executor.py b/dev/benchmark/src/executor/executor.py index 657f2549..688c1cfb 100644 --- a/dev/benchmark/src/executor/executor.py +++ b/dev/benchmark/src/executor/executor.py @@ -39,7 +39,7 @@ async def run_func( @abstractmethod def run( - self, func: Callable[[], Awaitable[None]], num_workers: int = 1 + self, func: Callable[[], Awaitable[Any]], num_workers: int = 1 ) -> list[ExecutionResult]: """Run the given asynchronous function using the specified number of workers. diff --git a/dev/benchmark/src/executor/thread_executor.py b/dev/benchmark/src/executor/thread_executor.py index c11bd2b8..ee3ce532 100644 --- a/dev/benchmark/src/executor/thread_executor.py +++ b/dev/benchmark/src/executor/thread_executor.py @@ -30,7 +30,7 @@ def _func(exe_id: int) -> ExecutionResult: results: list[ExecutionResult] = [] with ThreadPoolExecutor(max_workers=num_workers) as executor: - futures = [executor.submit(lambda: _func(i)) for i in range(num_workers)] + futures = [executor.submit(_func, i) for i in range(num_workers)] for future in futures: results.append(future.result()) diff --git a/dev/benchmark/src/payload_sender.py b/dev/benchmark/src/payload_sender.py index 33abd2b4..a27f87c0 100644 --- a/dev/benchmark/src/payload_sender.py +++ b/dev/benchmark/src/payload_sender.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import asyncio import requests from typing import Callable, Awaitable, Any @@ -10,18 +11,21 @@ def create_payload_sender( payload: dict[str, Any], timeout: int = 60 -) -> Callable[..., Awaitable[None]]: +) -> Callable[..., Awaitable[Any]]: + """Create a payload sender function that sends the given payload to the configured endpoint. - token = generate_token_from_env() + :param payload: The payload to be sent. + :param timeout: The timeout for the request in seconds. + :return: A callable that sends the payload when invoked. + """ + token = generate_token_from_env() endpoint = BenchmarkConfig.AGENT_URL - headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async def payload_sender() -> Any: - - response = requests.post( - endpoint, headers=headers, json=payload, timeout=timeout + response = await asyncio.to_thread( + requests.post, endpoint, headers=headers, json=payload, timeout=timeout ) return response.content diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 55e6cd03..3c531e5e 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -54,6 +54,13 @@ class MsalAuth(AccessTokenProviderBase): _client_credential_cache = None def __init__(self, msal_configuration: AgentAuthConfiguration): + """Initializes the MsalAuth class with the given configuration. + + :param msal_configuration: The MSAL authentication configuration. Assumed to + not be mutated after being passed in. + :type msal_configuration: AgentAuthConfiguration + """ + self._msal_configuration = msal_configuration self._msal_auth_client = None logger.debug( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py index 266fff50..8b0ef60d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py @@ -119,9 +119,8 @@ async def _get_public_key_or_secret(self, token: str) -> PyJWK: raise ValueError("Issuer (iss) claim is missing in the token.") self._init_jwks_client(issuer) - def func(): - return self._get_client(issuer).get_signing_key(header["kid"]) - - key = await asyncio.to_thread(func) + key = await asyncio.to_thread( + self._get_client(issuer).get_signing_key, header["kid"] + ) return key From dd252f1ac73340aa7c38a0384c5b552c5ba0eaa2 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 17 Oct 2025 09:04:29 -0700 Subject: [PATCH 17/21] Cleanup --- dev/benchmark/README.md | 10 +++++++-- dev/benchmark/src/aggregated_results.py | 21 +++++++++++++++++++ .../src/executor/coroutine_executor.py | 10 +++++---- dev/benchmark/src/generate_token.py | 5 +---- dev/benchmark/src/main.py | 6 ++++-- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/dev/benchmark/README.md b/dev/benchmark/README.md index 78ff2067..c64b118d 100644 --- a/dev/benchmark/README.md +++ b/dev/benchmark/README.md @@ -1,6 +1,6 @@ A simple benchmarking tool. -## Benchmark Python Environment Setup (Windows) +## Benchmark Python Environment Manual Setup (Windows) Currently a version of this tool that spawns async workers/coroutines instead of concurrent threads is not supported, so if you use a "normal" (non free-threaded) version @@ -9,7 +9,13 @@ of Python, you will be running with the global interpreter lock (GIL). Note: This may or may not incur significant changes in performance over using free-threaded concurrent tests or async workers, depending on the test scenario. -Install any Python version >= 3.9. Then, set up and activate the virtual environment with: +Install any Python version >= 3.9. Check with: + +```bash +python --version +``` + +Then, set up and activate the virtual environment with: ```bash python -m venv venv diff --git a/dev/benchmark/src/aggregated_results.py b/dev/benchmark/src/aggregated_results.py index 1e346310..b1edaa5e 100644 --- a/dev/benchmark/src/aggregated_results.py +++ b/dev/benchmark/src/aggregated_results.py @@ -28,3 +28,24 @@ def display(self, start_time: float, end_time: float): print(f"Total Time: {end_time - start_time} seconds") print("----------------------------") print() + + def display_timeline(self): + """Display timeline of individual execution results.""" + print() + print("---- Execution Timeline ----") + print( + "Each '.' represents 1 second of successful execution. So a line like '...' is a success that took 3 seconds (rounded up), 'x' represents a failure." + ) + print() + for result in sorted(self._results, key=lambda r: r.exe_id): + c = "." if result.success else "x" + if c == ".": + duration = int(round(result.duration)) + for _ in range(1 + duration): + print(c, end="") + print() + else: + print(c) + + print("----------------------------") + print() diff --git a/dev/benchmark/src/executor/coroutine_executor.py b/dev/benchmark/src/executor/coroutine_executor.py index 2f5ed45f..b9f4c191 100644 --- a/dev/benchmark/src/executor/coroutine_executor.py +++ b/dev/benchmark/src/executor/coroutine_executor.py @@ -19,7 +19,9 @@ def run( # :param func: An asynchronous function to be executed. # :param num_workers: The number of coroutines to use. # """ - # return asyncio.run( - # asyncio.gather(*[self.run_func(i, func) for i in range(num_workers)]) - # ) - raise NotImplementedError("CoroutineExecutor.run is not implemented yet.") + async def gather(): + return await asyncio.gather( + *[self.run_func(i, func) for i in range(num_workers)] + ) + + return asyncio.run(gather()) diff --git a/dev/benchmark/src/generate_token.py b/dev/benchmark/src/generate_token.py index fac8fbd2..c4406edd 100644 --- a/dev/benchmark/src/generate_token.py +++ b/dev/benchmark/src/generate_token.py @@ -6,7 +6,7 @@ def generate_token(app_id: str, app_secret: str) -> str: - """Generate a Direct Line token using the provided client ID and Direct Line secret.""" + """Generate a Direct Line token using the provided app credentials.""" url = URL.format(tenant_id=BenchmarkConfig.TENANT_ID) @@ -23,9 +23,6 @@ def generate_token(app_id: str, app_secret: str) -> str: }, timeout=10, ) - print(BenchmarkConfig.TENANT_ID) - print(res.json()) - # print(res.json().get("access_token")) return res.json().get("access_token") diff --git a/dev/benchmark/src/main.py b/dev/benchmark/src/main.py index 98c26967..af9e177e 100644 --- a/dev/benchmark/src/main.py +++ b/dev/benchmark/src/main.py @@ -17,11 +17,12 @@ @click.command() @click.option( - "--payload_path", default="./payload.json", help="Path to the payload file." + "--payload_path", "-p", default="./payload.json", help="Path to the payload file." ) -@click.option("--num_workers", default=1, help="Number of workers to use.") +@click.option("--num_workers", "-n", default=1, help="Number of workers to use.") @click.option( "--async_mode", + "-a", is_flag=True, help="Run coroutine workers rather than thread workers.", ) @@ -41,6 +42,7 @@ def main(payload_path: str, num_workers: int, async_mode: bool): agg = AggregatedResults(results) agg.display(start_time, end_time) + agg.display_timeline() if __name__ == "__main__": From 8b9c08117d98ed4f2508a9d379f35434f560927f Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Fri, 17 Oct 2025 09:07:35 -0700 Subject: [PATCH 18/21] Removed unneeded comment --- dev/benchmark/src/generate_token.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dev/benchmark/src/generate_token.py b/dev/benchmark/src/generate_token.py index c4406edd..323c952b 100644 --- a/dev/benchmark/src/generate_token.py +++ b/dev/benchmark/src/generate_token.py @@ -1,7 +1,6 @@ import requests from .config import BenchmarkConfig -# URL = "https://directline.botframework.com/v3/directline/tokens/generate" URL = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" From bfa2f53bd3b5fa7e467d711fe708a6cb321ca0bd Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 20 Oct 2025 09:19:38 -0700 Subject: [PATCH 19/21] Addressing PR comments --- dev/benchmark/src/executor/coroutine_executor.py | 9 +++++---- dev/benchmark/src/generate_token.py | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/dev/benchmark/src/executor/coroutine_executor.py b/dev/benchmark/src/executor/coroutine_executor.py index b9f4c191..5d03ff19 100644 --- a/dev/benchmark/src/executor/coroutine_executor.py +++ b/dev/benchmark/src/executor/coroutine_executor.py @@ -14,11 +14,12 @@ class CoroutineExecutor(Executor): def run( self, func: Callable[[], Awaitable[Any]], num_workers: int = 1 ) -> list[ExecutionResult]: - # """Run the given asynchronous function using the specified number of coroutines. + """Run the given asynchronous function using the specified number of coroutines. + + :param func: An asynchronous function to be executed. + :param num_workers: The number of coroutines to use. + """ - # :param func: An asynchronous function to be executed. - # :param num_workers: The number of coroutines to use. - # """ async def gather(): return await asyncio.gather( *[self.run_func(i, func) for i in range(num_workers)] diff --git a/dev/benchmark/src/generate_token.py b/dev/benchmark/src/generate_token.py index 323c952b..19c0e93e 100644 --- a/dev/benchmark/src/generate_token.py +++ b/dev/benchmark/src/generate_token.py @@ -5,7 +5,7 @@ def generate_token(app_id: str, app_secret: str) -> str: - """Generate a Direct Line token using the provided app credentials.""" + """Generate a token using the provided app credentials.""" url = URL.format(tenant_id=BenchmarkConfig.TENANT_ID) @@ -26,7 +26,7 @@ def generate_token(app_id: str, app_secret: str) -> str: def generate_token_from_env() -> str: - """Generates a Direct Line token using environment variables.""" + """Generates a token using environment variables.""" app_id = BenchmarkConfig.APP_ID app_secret = BenchmarkConfig.APP_SECRET if not app_id or not app_secret: From 6b0099658fa7fe0530206593df429f3f31caa325 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 20 Oct 2025 09:25:34 -0700 Subject: [PATCH 20/21] Removing unnecessary caching of the jwk's client --- .../core/authorization/jwt_token_validator.py | 82 ++----------------- 1 file changed, 7 insertions(+), 75 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py index 8b0ef60d..153f013a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py @@ -14,31 +14,11 @@ class JwtTokenValidator: - """Validates JWT tokens issued by Azure AD.""" - def __init__(self, configuration: AgentAuthConfiguration): - """Initialize the JwtTokenValidator with the given configuration. - - :param configuration: The AgentAuthConfiguration instance containing settings. - :type configuration: AgentAuthConfiguration - :raises ValueError: If configuration is None. - """ - if not configuration: - raise ValueError("Configuration cannot be None.") - self.configuration = configuration - self._default_jwks_client = None - self._tenant_jwks_client = None async def validate_token(self, token: str) -> ClaimsIdentity: - """Validate the given JWT token and return a ClaimsIdentity. - :param token: The JWT token to validate. - :type token: str - :return: A ClaimsIdentity representing the validated token. - :rtype: ClaimsIdentity - :raises ValueError: If the token is invalid or the audience does not match. - """ logger.debug("Validating JWT token.") key = await self._get_public_key_or_secret(token) decoded_token = jwt.decode( @@ -57,70 +37,22 @@ async def validate_token(self, token: str) -> ClaimsIdentity: return ClaimsIdentity(decoded_token, True) def get_anonymous_claims(self) -> ClaimsIdentity: - """Return an anonymous ClaimsIdentity.""" logger.debug("Returning anonymous claims identity.") return ClaimsIdentity({}, False, authentication_type="Anonymous") - def _get_client(self, issuer: str) -> PyJWKClient: - """Get the appropriate JWKS client based on the issuer. - - :param issuer: The issuer URL from the token. - :type issuer: str - :return: The corresponding PyJWKClient instance. - :rtype: PyJWKClient - """ - - client = None - if issuer == "https://api.botframework.com": - client = self._default_jwks_client - else: - client = self._tenant_jwks_client - if not client: - raise RuntimeError("JWKS client is not initialized.") - return client - - def _init_jwks_client(self, issuer: str) -> None: - """Initialize the JWKS client based on the issuer. - - :param issuer: The issuer URL from the token. - :type issuer: str - """ - - client_options = {"cache_keys": True} - - if issuer == "https://api.botframework.com": - if self._default_jwks_client is None: - self._default_jwks_client = PyJWKClient( - "https://login.botframework.com/v1/.well-known/keys", - **client_options, - ) - else: - if self._tenant_jwks_client is None: - self._tenant_jwks_client = PyJWKClient( - f"https://login.microsoftonline.com/{self.configuration.TENANT_ID}/discovery/v2.0/keys", - **client_options, - ) - async def _get_public_key_or_secret(self, token: str) -> PyJWK: - """Extract the public key or secret from the JWT token. - - :param token: The JWT token. - :type token: str - :return: The public key or secret used to verify the token. - :rtype: PyJWK - :raises ValueError: If the issuer claim is missing in the token. - """ - header = get_unverified_header(token) unverified_payload: dict = decode(token, options={"verify_signature": False}) - issuer = unverified_payload.get("iss") - if not issuer: - raise ValueError("Issuer (iss) claim is missing in the token.") - self._init_jwks_client(issuer) + jwksUri = ( + "https://login.botframework.com/v1/.well-known/keys" + if unverified_payload.get("iss") == "https://api.botframework.com" + else f"https://login.microsoftonline.com/{self.configuration.TENANT_ID}/discovery/v2.0/keys" + ) + jwks_client = PyJWKClient(jwksUri) key = await asyncio.to_thread( - self._get_client(issuer).get_signing_key, header["kid"] + jwks_client.get_signing_key, header["kid"] ) return key From f92e91ede5b2eb7d6f187f58187be70f2a375f48 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Mon, 20 Oct 2025 09:41:56 -0700 Subject: [PATCH 21/21] Formatting --- .../hosting/core/authorization/jwt_token_validator.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py index 153f013a..9069e81d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py @@ -51,8 +51,6 @@ async def _get_public_key_or_secret(self, token: str) -> PyJWK: ) jwks_client = PyJWKClient(jwksUri) - key = await asyncio.to_thread( - jwks_client.get_signing_key, header["kid"] - ) + key = await asyncio.to_thread(jwks_client.get_signing_key, header["kid"]) return key