From fe6b6344f7379f83cdaee8107dd5f4478de393f4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 09:11:56 -0700 Subject: [PATCH 1/4] Adding jwt decorator from user's suggestion --- .../hosting/fastapi/__init__.py | 5 ++ .../hosting/fastapi/_start_agent_process.py | 3 + .../hosting/fastapi/cloud_adapter.py | 1 + .../fastapi/jwt_authorization_middleware.py | 70 ++++++++++++++++++- test_samples/fastapi/empty_agent.py | 13 ++-- 5 files changed, 84 insertions(+), 8 deletions(-) diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py index e72ee8d85..d10a27359 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/__init__.py @@ -1,9 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from ._start_agent_process import start_agent_process from .agent_http_adapter import AgentHttpAdapter from .channel_service_route_table import channel_service_route_table from .cloud_adapter import CloudAdapter from .jwt_authorization_middleware import ( JwtAuthorizationMiddleware, + jwt_authorization_decorator, ) # Import streaming utilities from core for backward compatibility @@ -18,6 +22,7 @@ "AgentHttpAdapter", "CloudAdapter", "JwtAuthorizationMiddleware", + "jwt_authorization_decorator", "channel_service_route_table", "Citation", "CitationUtil", diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_start_agent_process.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_start_agent_process.py index ebaf2e439..24f982508 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_start_agent_process.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/_start_agent_process.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from typing import Optional from fastapi import Request, Response from microsoft_agents.hosting.core import error_resources diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py index a94f81df1..daf80cdaf 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from typing import Optional from fastapi import Request, Response diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py index 83b3fceb1..3f8b5b190 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py @@ -1,6 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import functools +import logging + from fastapi import Request from fastapi.responses import JSONResponse -import logging + from starlette.types import ASGIApp, Receive, Scope, Send from microsoft_agents.hosting.core import ( AgentAuthConfiguration, @@ -30,10 +36,18 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): app = scope.get("app") state = getattr(app, "state", None) if app else None - auth_config: AgentAuthConfiguration = getattr( + auth_config: AgentAuthConfiguration | None = getattr( state, "agent_configuration", None ) + if not auth_config: + response = JSONResponse( + {"error": "Agent Authentication configuration not found"}, + status_code=500, + ) + await response(scope, receive, send) + return + request = Request(scope, receive=receive) token_validator = JwtTokenValidator(auth_config) auth_header = request.headers.get("Authorization") @@ -72,3 +86,55 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): return await self.app(scope, receive, send) + + +def jwt_authorization_decorator(func): + """ + JWT Authorization Decorator for FastAPI endpoints. Until a SDK solution is made available, + this decorator can be applied to any FastAPI route handler to enforce JWT validation using the Microsoft Agents SDK's JwtTokenValidator. + """ + + @functools.wraps(func) + async def wrapper(request: Request): + if request is None: + return JSONResponse({"error": "Request object not found"}, status_code=500) + + auth_config: AgentAuthConfiguration | None = getattr( + request.app.state, "agent_configuration", None + ) + + if auth_config is None: + return JSONResponse( + {"error": "Agent Authentication configuration not found"}, + status_code=500, + ) + + token_validator = JwtTokenValidator(auth_config) + auth_header = request.headers.get("Authorization") + + if auth_header: + parts = auth_header.split(" ") + if len(parts) == 2 and parts[0].lower() == "bearer": + token = parts[1] + try: + claims = await token_validator.validate_token(token) + request.state.claims_identity = claims + except ValueError: + return JSONResponse( + {"error": "Invalid token or authentication failed."}, + status_code=401, + ) + else: + return JSONResponse( + {"error": "Invalid authorization header format"}, + status_code=401, + ) + else: + return JSONResponse( + {"error": "Authorization header not found"}, + status_code=401, + ) + + return await func(request) + + return wrapper diff --git a/test_samples/fastapi/empty_agent.py b/test_samples/fastapi/empty_agent.py index e918276a4..53007d166 100644 --- a/test_samples/fastapi/empty_agent.py +++ b/test_samples/fastapi/empty_agent.py @@ -18,9 +18,10 @@ from microsoft_agents.hosting.fastapi import ( CloudAdapter, start_agent_process, - JwtAuthorizationMiddleware, + jwt_authorization_decorator, ) from microsoft_agents.authentication.msal import MsalConnectionManager + # Create the agent application load_dotenv() @@ -38,7 +39,9 @@ # Create FastAPI app app = FastAPI(title="Empty Agent Sample", version="1.0.0") -app.add_middleware(JwtAuthorizationMiddleware) +app.state.agent_configuration = ( + CONNECTION_MANAGER.get_default_connection_configuration() +) # Agent handlers @@ -60,6 +63,7 @@ async def on_message(context: TurnContext, _): # FastAPI routes @app.post("/api/messages") +@jwt_authorization_decorator async def messages_handler( request: Request, ): @@ -79,9 +83,6 @@ async def messages_get(): if __name__ == "__main__": - - app.state.agent_configuration = (CONNECTION_MANAGER.get_default_connection_configuration()) - app.add_middleware(JwtAuthorizationMiddleware) port = int(environ.get("PORT", 3978)) - uvicorn.run(app, host="0.0.0.0", port=port) + uvicorn.run(app, host="127.0.0.1", port=port) From e8d0795038b843b4fe68f9198324455700e8ad6b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 16 Jul 2026 09:12:50 -0700 Subject: [PATCH 2/4] Updating authorization_agent sample --- test_samples/fastapi/authorization_agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test_samples/fastapi/authorization_agent.py b/test_samples/fastapi/authorization_agent.py index 81c8bf1c5..c5142103a 100644 --- a/test_samples/fastapi/authorization_agent.py +++ b/test_samples/fastapi/authorization_agent.py @@ -21,7 +21,7 @@ from microsoft_agents.hosting.fastapi import ( CloudAdapter, start_agent_process, - JwtAuthorizationMiddleware, + jwt_authorization_decorator, ) from microsoft_agents.authentication.msal import MsalConnectionManager @@ -141,11 +141,11 @@ async def message(context: TurnContext, state: TurnState) -> None: app.state.agent_configuration = ( CONNECTION_MANAGER.get_default_connection_configuration() ) -app.add_middleware(JwtAuthorizationMiddleware) # FastAPI routes @app.post("/api/messages") +@jwt_authorization_decorator async def messages_handler( request: Request, ): From a8ada989ebae685baed978e2c890cd273b038df5 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Jul 2026 15:42:51 -0700 Subject: [PATCH 3/4] Reusable _authorize_request for jwt middleware --- .../hosting/aiohttp/cloud_adapter.py | 1 + .../aiohttp/jwt_authorization_middleware.py | 74 ++++++--------- .../microsoft_agents/hosting/core/__init__.py | 2 +- .../hosting/core/authorization/__init__.py | 5 +- .../core/authorization/jwt/__init__.py | 10 +++ .../authorization/jwt/_authorize_request.py | 62 +++++++++++++ .../{ => jwt}/jwt_token_validator.py | 4 +- .../fastapi/jwt_authorization_middleware.py | 90 ++++--------------- 8 files changed, 124 insertions(+), 124 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/{ => jwt}/jwt_token_validator.py (97%) 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 659163409..00ff44646 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 @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from typing import Optional from aiohttp.web import Request, Response, json_response 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 f09604ba7..344c4bf55 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 @@ -1,63 +1,45 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import functools -from aiohttp.web import Request, middleware, json_response -from microsoft_agents.hosting.core.authorization import ( - AgentAuthConfiguration, - JwtTokenValidator, -) +from aiohttp.web import Request, middleware, json_response +from microsoft_agents.hosting.core.authorization import AgentAuthConfiguration +from microsoft_agents.hosting.core.authorization.jwt import _authorize_request +from microsoft_agents.hosting.core.http import HttpResponse -@middleware -async def jwt_authorization_middleware(request: Request, handler): +async def _jwt_authorization_middleware(request: Request, handler): + """ + JWT Authorization Middleware for aiohttp endpoints. Until a SDK solution is made available, + this middleware can be applied to any aiohttp route handler to enforce JWT validation using the Microsoft Agents SDK's JwtTokenValidator. + """ auth_config: AgentAuthConfiguration = request.app["agent_configuration"] - token_validator = JwtTokenValidator(auth_config) auth_header = request.headers.get("Authorization") - if auth_header: - # Extract the token from the Authorization header - token = auth_header.split(" ")[1] - try: - claims = await token_validator.validate_token(token) - request["claims_identity"] = claims - except ValueError as e: - print(f"JWT validation error: {e}") - return json_response({"error": str(e)}, status=401) - else: - if auth_config.ANONYMOUS_ALLOWED: - request["claims_identity"] = token_validator.get_anonymous_claims() - else: - return json_response( - {"error": "Authorization header not found"}, status=401 - ) + res = await _authorize_request(auth_header, auth_config) + + if isinstance(res, HttpResponse): + return json_response(res.body, status=res.status_code) + request["claims_identity"] = res return await handler(request) +jwt_authorization_middleware = middleware(_jwt_authorization_middleware) + + def jwt_authorization_decorator(func): + """ + Decorator for aiohttp route handlers to enforce JWT validation using the Microsoft Agents SDK's JwtTokenValidator. + + :param func: The aiohttp route handler function to be decorated. + :return: The decorated aiohttp route handler function. + """ + @functools.wraps(func) async def wrapper(request): - auth_config: AgentAuthConfiguration = request.app["agent_configuration"] - token_validator = JwtTokenValidator(auth_config) - auth_header = request.headers.get("Authorization") - if auth_header: - # Extract the token from the Authorization header - token = auth_header.split(" ")[1] - try: - claims = await token_validator.validate_token(token) - request["claims_identity"] = claims - except ValueError as e: - print(f"JWT validation error: {e}") - return json_response({"error": str(e)}, status=401) - else: - if not auth_config.CLIENT_ID: - # TODO: Refine anonymous strategy - request["claims_identity"] = token_validator.get_anonymous_claims() - else: - return json_response( - {"error": "Authorization header not found"}, status=401 - ) - - return await func(request) + return await _jwt_authorization_middleware(request, func) return wrapper diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index 5dfe4d95a..292765a48 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -57,7 +57,7 @@ from .authorization.connection_manager import ConnectionManager from .authorization.agent_auth_configuration import AgentAuthConfiguration from .authorization.claims_identity import ClaimsIdentity -from .authorization.jwt_token_validator import JwtTokenValidator +from .authorization.jwt.jwt_token_validator import JwtTokenValidator from .authorization.auth_types import AuthTypes # Client API diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/__init__.py index e8cd46b69..200ed628f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/__init__.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + from .access_token_provider_base import AccessTokenProviderBase from .authentication_constants import AuthenticationConstants from .anonymous_token_provider import AnonymousTokenProvider @@ -5,7 +8,7 @@ from .connection_manager import ConnectionManager from .agent_auth_configuration import AgentAuthConfiguration from .claims_identity import ClaimsIdentity -from .jwt_token_validator import JwtTokenValidator +from .jwt import JwtTokenValidator from .auth_types import AuthTypes __all__ = [ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/__init__.py new file mode 100644 index 000000000..316cae4fa --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from ._authorize_request import _authorize_request +from .jwt_token_validator import JwtTokenValidator + +__all__ = [ + "JwtTokenValidator", + "_authorize_request", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py new file mode 100644 index 000000000..e97b70d0d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging + +from dataclasses import dataclass + +from microsoft_agents.hosting.core.http import HttpResponse + +from ..agent_auth_configuration import AgentAuthConfiguration +from ..claims_identity import ClaimsIdentity +from .jwt_token_validator import JwtTokenValidator + +logger = logging.getLogger(__name__) + + +async def _authorize_request( + authorization_header: str | None, auth_config: AgentAuthConfiguration | None +) -> ClaimsIdentity | HttpResponse: + """ + Authorizes a request based on the provided JWT token in the Authorization header. + + Args: + authorization_header: The value of the Authorization header from the request. + auth_config: The agent authentication configuration. + + Returns: + _JwtAuthorizationResult: The result of the authorization attempt. + """ + + if auth_config is None: + return HttpResponse( + body={"error": "Agent Authentication configuration not found"}, + status_code=500, + ) + validator = JwtTokenValidator(auth_config) + + if not authorization_header: + if auth_config.ANONYMOUS_ALLOWED: + claims_identity = validator.get_anonymous_claims() + return claims_identity + return HttpResponse( + body={"error": "Authorization header not found"}, + status_code=401, + ) + + parts = authorization_header.split(" ") + if len(parts) != 2 or parts[0].lower() != "bearer": + return HttpResponse( + body={"error": "Invalid authorization header format"}, + status_code=401, + ) + + try: + claims = await validator.validate_token(parts[1]) + return claims + except ValueError as e: + logger.warning("JWT validation error: %s", e) + return HttpResponse( + body={"error": "Invalid token or authentication failed."}, + status_code=401, + ) 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/jwt_token_validator.py similarity index 97% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt_token_validator.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_token_validator.py index 2c09c8c47..a46d13187 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/jwt_token_validator.py @@ -9,8 +9,8 @@ from jwt import PyJWKClient, PyJWK, decode, get_unverified_header -from .agent_auth_configuration import AgentAuthConfiguration -from .claims_identity import ClaimsIdentity +from ..agent_auth_configuration import AgentAuthConfiguration +from ..claims_identity import ClaimsIdentity logger = logging.getLogger(__name__) diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py index 3f8b5b190..25658a527 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py @@ -8,10 +8,14 @@ from fastapi.responses import JSONResponse from starlette.types import ASGIApp, Receive, Scope, Send + from microsoft_agents.hosting.core import ( AgentAuthConfiguration, + ClaimsIdentity, JwtTokenValidator, + HttpResponse, ) +from microsoft_agents.hosting.core.authorization.jwt import _authorize_request logger = logging.getLogger(__name__) @@ -40,50 +44,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): state, "agent_configuration", None ) - if not auth_config: - response = JSONResponse( - {"error": "Agent Authentication configuration not found"}, - status_code=500, - ) - await response(scope, receive, send) - return - request = Request(scope, receive=receive) - token_validator = JwtTokenValidator(auth_config) - auth_header = request.headers.get("Authorization") + res = await _authorize_request( + request.headers.get("Authorization"), auth_config + ) - if auth_header: - parts = auth_header.split(" ") - if len(parts) == 2 and parts[0].lower() == "bearer": - token = parts[1] - try: - claims = await token_validator.validate_token(token) - request.state.claims_identity = claims - except ValueError as e: - logger.warning("JWT validation error: %s", e) - response = JSONResponse( - {"error": "Invalid token or authentication failed."}, - status_code=401, - ) - await response(scope, receive, send) - return - else: - response = JSONResponse( - {"error": "Invalid authorization header format"}, - status_code=401, - ) - await response(scope, receive, send) - return - else: - if auth_config.ANONYMOUS_ALLOWED: - request.state.claims_identity = token_validator.get_anonymous_claims() - else: - response = JSONResponse( - {"error": "Authorization header not found"}, - status_code=401, - ) - await response(scope, receive, send) - return + if isinstance(res, HttpResponse): + response = JSONResponse(body=res.body, status_code=res.status_code) + await response(scope, receive, send) + return await self.app(scope, receive, send) @@ -95,7 +64,7 @@ def jwt_authorization_decorator(func): """ @functools.wraps(func) - async def wrapper(request: Request): + async def wrapper(request: Request, *args, **kwargs): if request is None: return JSONResponse({"error": "Request object not found"}, status_code=500) @@ -103,38 +72,11 @@ async def wrapper(request: Request): request.app.state, "agent_configuration", None ) - if auth_config is None: - return JSONResponse( - {"error": "Agent Authentication configuration not found"}, - status_code=500, - ) - - token_validator = JwtTokenValidator(auth_config) auth_header = request.headers.get("Authorization") - if auth_header: - parts = auth_header.split(" ") - if len(parts) == 2 and parts[0].lower() == "bearer": - token = parts[1] - try: - claims = await token_validator.validate_token(token) - request.state.claims_identity = claims - except ValueError: - return JSONResponse( - {"error": "Invalid token or authentication failed."}, - status_code=401, - ) - else: - return JSONResponse( - {"error": "Invalid authorization header format"}, - status_code=401, - ) - else: - return JSONResponse( - {"error": "Authorization header not found"}, - status_code=401, - ) - - return await func(request) + res = await _authorize_request(auth_header, auth_config) + if isinstance(res, HttpResponse): + return JSONResponse(body=res.body, status_code=res.status_code) + return await func(request, *args, **kwargs) return wrapper From 5de99a69c8c91cdbad720e6ce82cc9be002c0973 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 22 Jul 2026 16:00:12 -0700 Subject: [PATCH 4/4] Adding tests --- dev/integration/pyproject.toml | 2 + dev/integration/tests/auth/__init__.py | 0 dev/integration/tests/auth/auth.env | 13 + .../tests/auth/test_oauth_continuation.py | 354 ++++++++++++++++++ .../tests/jwt_validation/__init__.py | 2 + .../tests/jwt_validation/_helpers.py | 16 + .../tests/jwt_validation/jwt_anonymous.env | 4 + .../tests/jwt_validation/jwt_required.env | 4 + .../test_aiohttp_jwt_validation.py | 68 ++++ .../test_fastapi_jwt_validation.py | 146 ++++++++ .../hosting/aiohttp/cloud_adapter.py | 3 +- .../microsoft_agents/hosting/core/__init__.py | 2 +- .../core/{http => }/_http_adapter_base.py | 23 +- .../authorization/jwt/_authorize_request.py | 4 +- .../hosting/core/http/__init__.py | 2 - .../core/http/_channel_service_routes.py | 4 +- .../hosting/core/http/_http_response.py | 2 +- .../hosting/fastapi/cloud_adapter.py | 3 +- .../fastapi/jwt_authorization_middleware.py | 14 +- .../test_jwt_authorization_middleware.py | 131 +++++++ .../authorization/test_authorize_request.py | 97 +++++ .../authorization/test_jwk_client_manager.py | 2 +- .../telemetry/test_http_adapter_telemetry.py | 2 +- tests/hosting_fastapi/__init__.py | 2 + .../test_jwt_authorization_middleware.py | 157 ++++++++ 25 files changed, 1024 insertions(+), 33 deletions(-) create mode 100644 dev/integration/tests/auth/__init__.py create mode 100644 dev/integration/tests/auth/auth.env create mode 100644 dev/integration/tests/auth/test_oauth_continuation.py create mode 100644 dev/integration/tests/jwt_validation/__init__.py create mode 100644 dev/integration/tests/jwt_validation/_helpers.py create mode 100644 dev/integration/tests/jwt_validation/jwt_anonymous.env create mode 100644 dev/integration/tests/jwt_validation/jwt_required.env create mode 100644 dev/integration/tests/jwt_validation/test_aiohttp_jwt_validation.py create mode 100644 dev/integration/tests/jwt_validation/test_fastapi_jwt_validation.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/{http => }/_http_adapter_base.py (89%) create mode 100644 tests/hosting_aiohttp/test_jwt_authorization_middleware.py create mode 100644 tests/hosting_core/authorization/test_authorize_request.py create mode 100644 tests/hosting_fastapi/__init__.py create mode 100644 tests/hosting_fastapi/test_jwt_authorization_middleware.py diff --git a/dev/integration/pyproject.toml b/dev/integration/pyproject.toml index 0e0ea7111..ee77297e0 100644 --- a/dev/integration/pyproject.toml +++ b/dev/integration/pyproject.toml @@ -5,6 +5,8 @@ requires-python = ">=3.13" dependencies = [ "pytest", "pytest-asyncio", + "microsoft-agents-hosting-aiohttp", "microsoft-agents-hosting-dialogs", + "microsoft-agents-hosting-fastapi", "microsoft-agents-testing @ file:///${PROJECT_ROOT}/../microsoft-agents-testing", ] \ No newline at end of file diff --git a/dev/integration/tests/auth/__init__.py b/dev/integration/tests/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/dev/integration/tests/auth/auth.env b/dev/integration/tests/auth/auth.env new file mode 100644 index 000000000..232b5bfcc --- /dev/null +++ b/dev/integration/tests/auth/auth.env @@ -0,0 +1,13 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=test-app-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=test-client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=test-tenant-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__ANONYMOUS_ALLOWED=true + +CONNECTIONSMAP__0__CONNECTION=SERVICE_CONNECTION +CONNECTIONSMAP__0__SERVICEURL=* + +AGENTAPPLICATION__USERAUTHORIZATION__AUTO_SIGN_IN=true +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__test-auth__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=test-oauth-connection +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__test-auth__SETTINGS__TITLE=Sign in +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__test-auth__SETTINGS__TEXT=Sign in +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__test-auth__SETTINGS__TYPE=UserAuthorization diff --git a/dev/integration/tests/auth/test_oauth_continuation.py b/dev/integration/tests/auth/test_oauth_continuation.py new file mode 100644 index 000000000..d87482f42 --- /dev/null +++ b/dev/integration/tests/auth/test_oauth_continuation.py @@ -0,0 +1,354 @@ +import asyncio +import time +from pathlib import Path +from typing import Optional + +import pytest +from aiohttp import ClientSession + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + Channels, + ResourceResponse, + SignInConstants, + SignInResource, + TokenExchangeResource, + TokenOrSignInResourceResponse, + TokenPostResource, + TokenResponse, +) +from microsoft_agents.hosting.core import TurnContext, TurnState +from microsoft_agents.testing import ( + ActivityTemplate, + AgentClient, + AgentEnvironment, + AiohttpScenario, + ClientConfig, + ScenarioConfig, +) + +_APP_ID = "test-app-id" +_CONVERSATION_ID = "auth-continuation-conversation" +_ORIGINAL_TEXT = "slow auth continuation" +_REPLAY_REPLY = f"processed: {_ORIGINAL_TEXT}" +_HANDLER_DELAY_SECONDS = 0.75 +_TOKEN_EXCHANGE_ID = "token-exchange-id" +_OAUTH_CONNECTION_NAME = "test-oauth-connection" + + +class _AuthFlowTestState: + def reset(self) -> None: + self.token_available = False + self.exchange_requests = [] + self.get_token_or_sign_in_calls = [] + self.replay_started = asyncio.Event() + self.replay_completed = asyncio.Event() + self.replayed_activity: Activity | None = None + self.replayed_claims: dict[str, str] | None = None + + +_auth_flow = _AuthFlowTestState() +_auth_flow.reset() + + +class _FakeUserToken: + def __init__(self, state: _AuthFlowTestState): + self._state = state + + async def get_token( + self, + user_id: str, + connection_name: str, + channel_id: Optional[str] = None, + code: Optional[str] = None, + ) -> TokenResponse: + if self._state.token_available: + return TokenResponse( + connection_name=connection_name, + token="cached-token", + channel_id=channel_id, + ) + return TokenResponse() + + async def _get_token_or_sign_in_resource( + self, + user_id: str, + connection_name: str, + channel_id: str, + state: str, + code: str = "", + final_redirect: str = "", + fwd_url: str = "", + ) -> TokenOrSignInResourceResponse: + self._state.get_token_or_sign_in_calls.append( + { + "user_id": user_id, + "connection_name": connection_name, + "channel_id": channel_id, + } + ) + if self._state.token_available: + return TokenOrSignInResourceResponse( + token_response=TokenResponse( + connection_name=connection_name, + token="cached-token", + channel_id=channel_id, + ) + ) + return TokenOrSignInResourceResponse( + sign_in_resource=SignInResource( + sign_in_link="https://example.test/signin", + token_exchange_resource=TokenExchangeResource( + id=_TOKEN_EXCHANGE_ID, + uri="api://test-token-exchange", + provider_id="test-provider", + ), + token_post_resource=TokenPostResource( + sas_url="https://example.test/token-post" + ), + ) + ) + + async def get_aad_tokens( + self, + user_id: str, + connection_name: str, + channel_id: Optional[str] = None, + body: Optional[dict] = None, + ) -> dict[str, TokenResponse]: + return {} + + async def sign_out( + self, + user_id: str, + connection_name: Optional[str] = None, + channel_id: Optional[str] = None, + ) -> None: + self._state.token_available = False + + async def get_token_status( + self, + user_id: str, + channel_id: Optional[str] = None, + include: Optional[str] = None, + ) -> list: + return [] + + async def exchange_token( + self, + user_id: str, + connection_name: str, + channel_id: str, + body: Optional[dict] = None, + ) -> TokenResponse: + self._state.exchange_requests.append( + { + "user_id": user_id, + "connection_name": connection_name, + "channel_id": channel_id, + "body": body, + } + ) + self._state.token_available = True + return TokenResponse( + connection_name=connection_name, + token="exchanged-token", + channel_id=channel_id, + ) + + +class _FakeUserTokenClient: + def __init__(self, state: _AuthFlowTestState): + self._user_token = _FakeUserToken(state) + + @property + def user_token(self) -> _FakeUserToken: + return self._user_token + + @property + def agent_sign_in(self): + return None + + async def close(self) -> None: + return None + + +class _FakeConversations: + def __init__(self, service_url: str, session: ClientSession): + self._service_url = service_url.rstrip("/") + self._session = session + + async def send_to_conversation( + self, conversation_id: str, activity: Activity + ) -> ResourceResponse: + return await self._post_activity(conversation_id, activity) + + async def reply_to_activity( + self, conversation_id: str, activity_id: str, activity: Activity + ) -> ResourceResponse: + return await self._post_activity(conversation_id, activity, activity_id) + + async def _post_activity( + self, + conversation_id: str, + activity: Activity, + activity_id: Optional[str] = None, + ) -> ResourceResponse: + activity.id = activity.id or f"activity-{time.perf_counter_ns()}" + suffix = f"/{conversation_id}/activities" + if activity_id: + suffix = f"{suffix}/{activity_id}" + async with self._session.post( + f"{self._service_url}{suffix}", + json=activity.model_dump( + by_alias=True, + exclude_unset=True, + exclude_none=True, + mode="json", + ), + ) as response: + response.raise_for_status() + return ResourceResponse(id=activity.id) + + +class _FakeConnectorClient: + def __init__(self, service_url: str): + self._session = ClientSession() + self._conversations = _FakeConversations(service_url, self._session) + + @property + def base_uri(self) -> str: + return "" + + @property + def attachments(self): + return None + + @property + def conversations(self) -> _FakeConversations: + return self._conversations + + async def close(self) -> None: + await self._session.close() + + +class _FakeChannelServiceClientFactory: + def __init__(self, state: _AuthFlowTestState): + self._user_token_client = _FakeUserTokenClient(state) + + async def create_connector_client( + self, + context, + claims_identity, + service_url: str, + audience: str, + scopes: Optional[list[str]] = None, + use_anonymous: bool = False, + ) -> _FakeConnectorClient: + return _FakeConnectorClient(service_url) + + async def create_user_token_client( + self, + context, + claims_identity, + use_anonymous: bool = False, + ) -> _FakeUserTokenClient: + return self._user_token_client + + +async def init_agent(env: AgentEnvironment): + env.adapter._channel_service_client_factory = _FakeChannelServiceClientFactory( + _auth_flow + ) + + original_process_activity = env.adapter.process_activity + + async def process_activity_with_test_identity(claims_identity, activity, callback): + claims_identity.claims.setdefault("aud", _APP_ID) + claims_identity.claims.setdefault("appid", _APP_ID) + return await original_process_activity(claims_identity, activity, callback) + + env.adapter.process_activity = process_activity_with_test_identity + + app = env.agent_application + + @app.message(_ORIGINAL_TEXT) + async def message_handler(context: TurnContext, state: TurnState): + _auth_flow.replayed_activity = context.activity.model_copy(deep=True) + _auth_flow.replayed_claims = dict(context.identity.claims) + _auth_flow.replay_started.set() + await asyncio.sleep(_HANDLER_DELAY_SECONDS) + await context.send_activity(_REPLAY_REPLY) + _auth_flow.replay_completed.set() + + +_TEMPLATE = ActivityTemplate( + { + "channel_id": Channels.ms_teams, + "locale": "en-US", + "conversation": {"id": _CONVERSATION_ID}, + "from": {"id": "user-id", "name": "User"}, + "recipient": {"id": "agent-id", "name": "Agent"}, + } +) + +_SCENARIO = AiohttpScenario( + init_agent=init_agent, + config=ScenarioConfig( + env_file_path=str(Path(__file__).with_name("auth.env")), + client_config=ClientConfig(activity_template=_TEMPLATE), + ), + use_jwt_middleware=False, +) + + +@pytest.mark.asyncio +@pytest.mark.agent_test(_SCENARIO) +async def test_token_exchange_returns_before_continuation_replay_finishes( + agent_client: AgentClient, +): + _auth_flow.reset() + + original_exchange = (await agent_client.ex_send(_ORIGINAL_TEXT))[0] + original_activity = original_exchange.request + + token_exchange = Activity( + type=ActivityTypes.invoke, + name=SignInConstants.token_exchange_operation_name, + value={ + "id": _TOKEN_EXCHANGE_ID, + "connectionName": _OAUTH_CONNECTION_NAME, + "token": "sso-token", + }, + ) + + start = time.perf_counter() + invoke_response = await agent_client.invoke(token_exchange) + elapsed = time.perf_counter() - start + + assert invoke_response.status == 200 + assert elapsed < _HANDLER_DELAY_SECONDS / 2 + assert not _auth_flow.replay_completed.is_set() + + await asyncio.wait_for(_auth_flow.replay_completed.wait(), timeout=2.0) + + replies = [ + activity + for activity in agent_client.history() + if activity.type == ActivityTypes.message and activity.text == _REPLAY_REPLY + ] + assert len(replies) == 1 + + assert len(_auth_flow.exchange_requests) == 1 + assert len(_auth_flow.get_token_or_sign_in_calls) >= 2 + + assert _auth_flow.replayed_activity.type == ActivityTypes.message + assert _auth_flow.replayed_activity.text == original_activity.text + assert _auth_flow.replayed_activity.channel_id == original_activity.channel_id + assert ( + _auth_flow.replayed_activity.conversation.id + == original_activity.conversation.id + ) + assert _auth_flow.replayed_activity.service_url == original_activity.service_url + assert _auth_flow.replayed_claims["aud"] == _APP_ID diff --git a/dev/integration/tests/jwt_validation/__init__.py b/dev/integration/tests/jwt_validation/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/dev/integration/tests/jwt_validation/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/dev/integration/tests/jwt_validation/_helpers.py b/dev/integration/tests/jwt_validation/_helpers.py new file mode 100644 index 000000000..1d567293a --- /dev/null +++ b/dev/integration/tests/jwt_validation/_helpers.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from pathlib import Path + +from dotenv import dotenv_values + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.hosting.core.authorization import AgentAuthConfiguration + + +def load_auth_config(env_file_name: str) -> AgentAuthConfiguration: + env_vars = dotenv_values(Path(__file__).with_name(env_file_name)) + sdk_config = load_configuration_from_env(env_vars) + settings = sdk_config["CONNECTIONS"]["SERVICE_CONNECTION"]["SETTINGS"] + return AgentAuthConfiguration(**settings) diff --git a/dev/integration/tests/jwt_validation/jwt_anonymous.env b/dev/integration/tests/jwt_validation/jwt_anonymous.env new file mode 100644 index 000000000..7edbb3347 --- /dev/null +++ b/dev/integration/tests/jwt_validation/jwt_anonymous.env @@ -0,0 +1,4 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=test-app-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=test-client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=test-tenant-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__ANONYMOUS_ALLOWED=true diff --git a/dev/integration/tests/jwt_validation/jwt_required.env b/dev/integration/tests/jwt_validation/jwt_required.env new file mode 100644 index 000000000..533b75440 --- /dev/null +++ b/dev/integration/tests/jwt_validation/jwt_required.env @@ -0,0 +1,4 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=test-app-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=test-client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=test-tenant-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__ANONYMOUS_ALLOWED=false diff --git a/dev/integration/tests/jwt_validation/test_aiohttp_jwt_validation.py b/dev/integration/tests/jwt_validation/test_aiohttp_jwt_validation.py new file mode 100644 index 000000000..e8082ec7c --- /dev/null +++ b/dev/integration/tests/jwt_validation/test_aiohttp_jwt_validation.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from aiohttp import web + +from microsoft_agents.hosting.aiohttp import jwt_authorization_middleware + +from ._helpers import load_auth_config + + +@pytest.mark.asyncio +async def test_aiohttp_jwt_allows_anonymous_request_from_env_config(aiohttp_client): + async def handler(request): + identity = request["claims_identity"] + return web.json_response( + { + "authenticated": identity.is_authenticated, + "authentication_type": identity.authentication_type, + } + ) + + app = web.Application(middlewares=[jwt_authorization_middleware]) + app["agent_configuration"] = load_auth_config("jwt_anonymous.env") + app.router.add_get("/", handler) + + client = await aiohttp_client(app) + response = await client.get("/") + + assert response.status == 200 + assert await response.json() == { + "authenticated": False, + "authentication_type": "Anonymous", + } + + +@pytest.mark.asyncio +async def test_aiohttp_jwt_rejects_missing_authorization_header(aiohttp_client): + async def handler(request): + return web.json_response({"called": True}) + + app = web.Application(middlewares=[jwt_authorization_middleware]) + app["agent_configuration"] = load_auth_config("jwt_required.env") + app.router.add_get("/", handler) + + client = await aiohttp_client(app) + response = await client.get("/") + + assert response.status == 401 + assert await response.json() == {"error": "Authorization header not found"} + + +@pytest.mark.asyncio +async def test_aiohttp_jwt_rejects_invalid_bearer_token(aiohttp_client): + async def handler(request): + return web.json_response({"called": True}) + + app = web.Application(middlewares=[jwt_authorization_middleware]) + app["agent_configuration"] = load_auth_config("jwt_required.env") + app.router.add_get("/", handler) + + client = await aiohttp_client(app) + response = await client.get("/", headers={"Authorization": "Bearer not-a-jwt"}) + + assert response.status == 401 + assert await response.json() == { + "error": "Invalid token or authentication failed." + } diff --git a/dev/integration/tests/jwt_validation/test_fastapi_jwt_validation.py b/dev/integration/tests/jwt_validation/test_fastapi_jwt_validation.py new file mode 100644 index 000000000..7b7244740 --- /dev/null +++ b/dev/integration/tests/jwt_validation/test_fastapi_jwt_validation.py @@ -0,0 +1,146 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest +from fastapi import FastAPI, Request + +from microsoft_agents.hosting.fastapi import JwtAuthorizationMiddleware + +from ._helpers import load_auth_config + + +def _scope(app: FastAPI, authorization: str | None = None): + headers = [] + if authorization is not None: + headers.append((b"authorization", authorization.encode())) + return { + "type": "http", + "asgi": {"version": "3.0"}, + "method": "GET", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "headers": headers, + "client": ("127.0.0.1", 1234), + "server": ("testserver", 80), + "scheme": "http", + "app": app, + "state": {}, + } + + +async def _receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + +async def _send_json(send, status: int, body: bytes): + await send( + { + "type": "http.response.start", + "status": status, + "headers": [(b"content-type", b"application/json")], + } + ) + await send({"type": "http.response.body", "body": body}) + + +async def _record_send(messages, message): + messages.append(message) + + +def _status(messages): + return next( + message["status"] + for message in messages + if message["type"] == "http.response.start" + ) + + +def _body(messages): + return b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + + +@pytest.mark.asyncio +async def test_fastapi_jwt_allows_anonymous_request_from_env_config(): + app = FastAPI() + app.state.agent_configuration = load_auth_config("jwt_anonymous.env") + messages = [] + + async def downstream(scope, receive, send): + request = Request(scope, receive=receive) + identity = request.state.claims_identity + await _send_json( + send, + 200, + ( + b'{"authenticated":' + + str(identity.is_authenticated).lower().encode() + + b',"authentication_type":"' + + identity.authentication_type.encode() + + b'"}' + ), + ) + + middleware = JwtAuthorizationMiddleware(downstream) + await middleware( + _scope(app), + _receive, + lambda message: _record_send(messages, message), + ) + + assert _status(messages) == 200 + assert _body(messages) == ( + b'{"authenticated":false,"authentication_type":"Anonymous"}' + ) + + +@pytest.mark.asyncio +async def test_fastapi_jwt_rejects_missing_authorization_header(): + app = FastAPI() + app.state.agent_configuration = load_auth_config("jwt_required.env") + messages = [] + downstream_called = False + + async def downstream(scope, receive, send): + nonlocal downstream_called + downstream_called = True + await _send_json(send, 200, b'{"called":true}') + + middleware = JwtAuthorizationMiddleware(downstream) + await middleware( + _scope(app), + _receive, + lambda message: _record_send(messages, message), + ) + + assert downstream_called is False + assert _status(messages) == 401 + assert _body(messages) == b'{"error":"Authorization header not found"}' + + +@pytest.mark.asyncio +async def test_fastapi_jwt_rejects_invalid_bearer_token(): + app = FastAPI() + app.state.agent_configuration = load_auth_config("jwt_required.env") + messages = [] + downstream_called = False + + async def downstream(scope, receive, send): + nonlocal downstream_called + downstream_called = True + await _send_json(send, 200, b'{"called":true}') + + middleware = JwtAuthorizationMiddleware(downstream) + await middleware( + _scope(app, "Bearer not-a-jwt"), + _receive, + lambda message: _record_send(messages, message), + ) + + assert downstream_called is False + assert _status(messages) == 401 + assert _body(messages) == b'{"error":"Invalid token or authentication failed."}' 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 00ff44646..907579813 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 @@ -5,10 +5,9 @@ from aiohttp.web import Request, Response, json_response -from microsoft_agents.hosting.core import Agent +from microsoft_agents.hosting.core import Agent, HttpAdapterBase from microsoft_agents.hosting.core.authorization import Connections from microsoft_agents.hosting.core.http import ( - HttpAdapterBase, HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py index 292765a48..ecb9b153b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/__init__.py @@ -15,9 +15,9 @@ HttpRequestProtocol, HttpResponse, HttpResponseFactory, - HttpAdapterBase, ChannelServiceRoutes, ) +from ._http_adapter_base import HttpAdapterBase # Application Style from .app._type_defs import RouteHandler, RouteSelector, StateT diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_http_adapter_base.py similarity index 89% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_http_adapter_base.py index 869137fee..3cc217fbe 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_adapter_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/_http_adapter_base.py @@ -4,23 +4,22 @@ """Base HTTP adapter with shared processing logic.""" from abc import ABC -from traceback import format_exc from http import HTTPStatus +from traceback import format_exc from microsoft_agents.activity import Activity, DeliveryModes -from microsoft_agents.hosting.core.authorization import ClaimsIdentity, Connections -from microsoft_agents.hosting.core import ( - Agent, - ChannelServiceAdapter, - ChannelServiceClientFactoryBase, - MessageFactory, - RestChannelServiceClientFactory, - TurnContext, -) from microsoft_agents.hosting.core.telemetry.adapter import spans -from ._http_request_protocol import HttpRequestProtocol -from ._http_response import HttpResponse, HttpResponseFactory +from .agent import Agent +from .authorization.claims_identity import ClaimsIdentity +from .authorization.connections import Connections +from .channel_service_adapter import ChannelServiceAdapter +from .channel_service_client_factory_base import ChannelServiceClientFactoryBase +from .http._http_request_protocol import HttpRequestProtocol +from .http._http_response import HttpResponse, HttpResponseFactory +from .message_factory import MessageFactory +from .rest_channel_service_client_factory import RestChannelServiceClientFactory +from .turn_context import TurnContext class HttpAdapterBase(ChannelServiceAdapter, ABC): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py index e97b70d0d..77cc28cda 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py @@ -3,7 +3,7 @@ import logging -from dataclasses import dataclass +from jwt import PyJWTError from microsoft_agents.hosting.core.http import HttpResponse @@ -54,7 +54,7 @@ async def _authorize_request( try: claims = await validator.validate_token(parts[1]) return claims - except ValueError as e: + except (PyJWTError, ValueError) as e: logger.warning("JWT validation error: %s", e) return HttpResponse( body={"error": "Invalid token or authentication failed."}, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/__init__.py index 845002103..a7110274c 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/__init__.py @@ -5,13 +5,11 @@ from ._http_request_protocol import HttpRequestProtocol from ._http_response import HttpResponse, HttpResponseFactory -from ._http_adapter_base import HttpAdapterBase from ._channel_service_routes import ChannelServiceRoutes __all__ = [ "HttpRequestProtocol", "HttpResponse", "HttpResponseFactory", - "HttpAdapterBase", "ChannelServiceRoutes", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_channel_service_routes.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_channel_service_routes.py index beca9fba7..fa98fe1f4 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_channel_service_routes.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_channel_service_routes.py @@ -12,7 +12,9 @@ ConversationParameters, Transcript, ) -from microsoft_agents.hosting.core import ChannelApiHandlerProtocol +from microsoft_agents.hosting.core.channel_api_handler_protocol import ( + ChannelApiHandlerProtocol, +) from ._http_request_protocol import HttpRequestProtocol diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_response.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_response.py index efe686574..b1a5dc462 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_response.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/http/_http_response.py @@ -3,8 +3,8 @@ """HTTP response abstraction.""" -from typing import Any, Optional from dataclasses import dataclass +from typing import Any, Optional @dataclass diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py index daf80cdaf..88df51e38 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/cloud_adapter.py @@ -6,10 +6,9 @@ from fastapi import Request, Response from fastapi.responses import JSONResponse -from microsoft_agents.hosting.core import Agent +from microsoft_agents.hosting.core import Agent, HttpAdapterBase from microsoft_agents.hosting.core.authorization import Connections from microsoft_agents.hosting.core.http import ( - HttpAdapterBase, HttpResponse, ) from microsoft_agents.hosting.core import ChannelServiceClientFactoryBase diff --git a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py index 25658a527..63955a04d 100644 --- a/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py +++ b/libraries/microsoft-agents-hosting-fastapi/microsoft_agents/hosting/fastapi/jwt_authorization_middleware.py @@ -9,13 +9,9 @@ from starlette.types import ASGIApp, Receive, Scope, Send -from microsoft_agents.hosting.core import ( - AgentAuthConfiguration, - ClaimsIdentity, - JwtTokenValidator, - HttpResponse, -) +from microsoft_agents.hosting.core import AgentAuthConfiguration from microsoft_agents.hosting.core.authorization.jwt import _authorize_request +from microsoft_agents.hosting.core.http import HttpResponse logger = logging.getLogger(__name__) @@ -50,10 +46,11 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): ) if isinstance(res, HttpResponse): - response = JSONResponse(body=res.body, status_code=res.status_code) + response = JSONResponse(content=res.body, status_code=res.status_code) await response(scope, receive, send) return + request.state.claims_identity = res await self.app(scope, receive, send) @@ -76,7 +73,8 @@ async def wrapper(request: Request, *args, **kwargs): res = await _authorize_request(auth_header, auth_config) if isinstance(res, HttpResponse): - return JSONResponse(body=res.body, status_code=res.status_code) + return JSONResponse(content=res.body, status_code=res.status_code) + request.state.claims_identity = res return await func(request, *args, **kwargs) return wrapper diff --git a/tests/hosting_aiohttp/test_jwt_authorization_middleware.py b/tests/hosting_aiohttp/test_jwt_authorization_middleware.py new file mode 100644 index 000000000..1bc7c93d6 --- /dev/null +++ b/tests/hosting_aiohttp/test_jwt_authorization_middleware.py @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import AsyncMock, patch + +import pytest +from aiohttp import web + +from microsoft_agents.hosting.aiohttp.jwt_authorization_middleware import ( + jwt_authorization_decorator, + jwt_authorization_middleware, +) +from microsoft_agents.hosting.core.authorization import ( + AgentAuthConfiguration, + ClaimsIdentity, +) +from microsoft_agents.hosting.core.http import HttpResponse + + +def _set_agent_configuration(app: web.Application, auth_config: AgentAuthConfiguration): + app._state["agent_configuration"] = auth_config + + +@pytest.mark.asyncio +async def test_aiohttp_middleware_stores_claims_and_calls_handler(aiohttp_client): + auth_config = AgentAuthConfiguration() + claims = ClaimsIdentity({"aud": "app-id"}, True) + + async def handler(request): + return web.json_response( + {"aud": request["claims_identity"].claims["aud"]} + ) + + app = web.Application(middlewares=[jwt_authorization_middleware]) + _set_agent_configuration(app, auth_config) + app.router.add_get("/", handler) + + with patch( + "microsoft_agents.hosting.aiohttp.jwt_authorization_middleware._authorize_request", + new=AsyncMock(return_value=claims), + ) as authorize: + client = await aiohttp_client(app) + response = await client.get("/", headers={"Authorization": "Bearer token"}) + + assert response.status == 200 + assert await response.json() == {"aud": "app-id"} + authorize.assert_awaited_once_with("Bearer token", auth_config) + + +@pytest.mark.asyncio +async def test_aiohttp_middleware_converts_http_response(aiohttp_client): + auth_config = AgentAuthConfiguration() + + async def handler(request): + return web.json_response({"called": True}) + + app = web.Application(middlewares=[jwt_authorization_middleware]) + _set_agent_configuration(app, auth_config) + app.router.add_get("/", handler) + + with patch( + "microsoft_agents.hosting.aiohttp.jwt_authorization_middleware._authorize_request", + new=AsyncMock( + return_value=HttpResponse( + body={"error": "Invalid token or authentication failed."}, + status_code=401, + ) + ), + ) as authorize: + client = await aiohttp_client(app) + response = await client.get("/", headers={"Authorization": "Bearer bad"}) + + assert response.status == 401 + assert await response.json() == {"error": "Invalid token or authentication failed."} + authorize.assert_awaited_once_with("Bearer bad", auth_config) + + +@pytest.mark.asyncio +async def test_aiohttp_decorator_uses_authorization_helper(aiohttp_client): + auth_config = AgentAuthConfiguration() + claims = ClaimsIdentity({"aud": "decorator-app"}, True) + + @jwt_authorization_decorator + async def handler(request): + return web.json_response( + {"aud": request["claims_identity"].claims["aud"]} + ) + + app = web.Application() + _set_agent_configuration(app, auth_config) + app.router.add_get("/", handler) + + with patch( + "microsoft_agents.hosting.aiohttp.jwt_authorization_middleware._authorize_request", + new=AsyncMock(return_value=claims), + ) as authorize: + client = await aiohttp_client(app) + response = await client.get("/", headers={"Authorization": "Bearer token"}) + + assert response.status == 200 + assert await response.json() == {"aud": "decorator-app"} + authorize.assert_awaited_once_with("Bearer token", auth_config) + + +@pytest.mark.asyncio +async def test_aiohttp_decorator_converts_http_response(aiohttp_client): + auth_config = AgentAuthConfiguration() + + @jwt_authorization_decorator + async def handler(request): + return web.json_response({"called": True}) + + app = web.Application() + _set_agent_configuration(app, auth_config) + app.router.add_get("/", handler) + + with patch( + "microsoft_agents.hosting.aiohttp.jwt_authorization_middleware._authorize_request", + new=AsyncMock( + return_value=HttpResponse( + body={"error": "Authorization header not found"}, + status_code=401, + ) + ), + ) as authorize: + client = await aiohttp_client(app) + response = await client.get("/") + + assert response.status == 401 + assert await response.json() == {"error": "Authorization header not found"} + authorize.assert_awaited_once_with(None, auth_config) diff --git a/tests/hosting_core/authorization/test_authorize_request.py b/tests/hosting_core/authorization/test_authorize_request.py new file mode 100644 index 000000000..bf7708ad9 --- /dev/null +++ b/tests/hosting_core/authorization/test_authorize_request.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from microsoft_agents.hosting.core.authorization import ( + AgentAuthConfiguration, + ClaimsIdentity, +) +from microsoft_agents.hosting.core.authorization.jwt import _authorize_request +from microsoft_agents.hosting.core.http import HttpResponse + + +@pytest.mark.asyncio +async def test_authorize_request_returns_500_when_config_is_missing(): + with patch( + "microsoft_agents.hosting.core.authorization.jwt._authorize_request.JwtTokenValidator" + ) as validator_cls: + result = await _authorize_request("Bearer token", None) + + assert isinstance(result, HttpResponse) + assert result.status_code == 500 + assert result.body == {"error": "Agent Authentication configuration not found"} + validator_cls.assert_not_called() + + +@pytest.mark.asyncio +async def test_authorize_request_returns_401_when_header_is_missing_and_anonymous_disabled(): + result = await _authorize_request(None, AgentAuthConfiguration()) + + assert isinstance(result, HttpResponse) + assert result.status_code == 401 + assert result.body == {"error": "Authorization header not found"} + + +@pytest.mark.asyncio +async def test_authorize_request_returns_anonymous_claims_when_header_is_missing_and_anonymous_enabled(): + auth_config = AgentAuthConfiguration(anonymous_allowed=True) + claims = ClaimsIdentity({}, False, authentication_type="Anonymous") + validator = MagicMock() + validator.get_anonymous_claims.return_value = claims + + with patch( + "microsoft_agents.hosting.core.authorization.jwt._authorize_request.JwtTokenValidator", + return_value=validator, + ) as validator_cls: + result = await _authorize_request(None, auth_config) + + assert result is claims + validator_cls.assert_called_once_with(auth_config) + validator.get_anonymous_claims.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_authorize_request_returns_401_for_invalid_authorization_header_format(): + result = await _authorize_request("Basic token", AgentAuthConfiguration()) + + assert isinstance(result, HttpResponse) + assert result.status_code == 401 + assert result.body == {"error": "Invalid authorization header format"} + + +@pytest.mark.asyncio +async def test_authorize_request_validates_bearer_token(): + auth_config = AgentAuthConfiguration() + claims = ClaimsIdentity({"aud": "app-id"}, True) + validator = MagicMock() + validator.validate_token = AsyncMock(return_value=claims) + + with patch( + "microsoft_agents.hosting.core.authorization.jwt._authorize_request.JwtTokenValidator", + return_value=validator, + ) as validator_cls: + result = await _authorize_request("Bearer token-value", auth_config) + + assert result is claims + validator_cls.assert_called_once_with(auth_config) + validator.validate_token.assert_awaited_once_with("token-value") + + +@pytest.mark.asyncio +async def test_authorize_request_returns_401_when_token_validation_fails(): + validator = MagicMock() + validator.validate_token = AsyncMock(side_effect=ValueError("bad token")) + + with patch( + "microsoft_agents.hosting.core.authorization.jwt._authorize_request.JwtTokenValidator", + return_value=validator, + ): + result = await _authorize_request("Bearer token-value", AgentAuthConfiguration()) + + assert isinstance(result, HttpResponse) + assert result.status_code == 401 + assert result.body == {"error": "Invalid token or authentication failed."} + validator.validate_token.assert_awaited_once_with("token-value") diff --git a/tests/hosting_core/authorization/test_jwk_client_manager.py b/tests/hosting_core/authorization/test_jwk_client_manager.py index 89aea3179..757c9fa39 100644 --- a/tests/hosting_core/authorization/test_jwk_client_manager.py +++ b/tests/hosting_core/authorization/test_jwk_client_manager.py @@ -5,7 +5,7 @@ import pytest from jwt import PyJWKClient -from microsoft_agents.hosting.core.authorization.jwt_token_validator import ( +from microsoft_agents.hosting.core.authorization.jwt.jwt_token_validator import ( _JwkClientManager, ) diff --git a/tests/hosting_core/telemetry/test_http_adapter_telemetry.py b/tests/hosting_core/telemetry/test_http_adapter_telemetry.py index b7ed889ac..0c07e998f 100644 --- a/tests/hosting_core/telemetry/test_http_adapter_telemetry.py +++ b/tests/hosting_core/telemetry/test_http_adapter_telemetry.py @@ -11,7 +11,7 @@ from unittest.mock import AsyncMock, MagicMock from opentelemetry import trace -from microsoft_agents.hosting.core.http._http_adapter_base import HttpAdapterBase +from microsoft_agents.hosting.core import HttpAdapterBase from microsoft_agents.hosting.core.telemetry.adapter import constants from microsoft_agents.hosting.core.telemetry import attributes diff --git a/tests/hosting_fastapi/__init__.py b/tests/hosting_fastapi/__init__.py new file mode 100644 index 000000000..5b7f7a925 --- /dev/null +++ b/tests/hosting_fastapi/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/tests/hosting_fastapi/test_jwt_authorization_middleware.py b/tests/hosting_fastapi/test_jwt_authorization_middleware.py new file mode 100644 index 000000000..64ce9a101 --- /dev/null +++ b/tests/hosting_fastapi/test_jwt_authorization_middleware.py @@ -0,0 +1,157 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest.mock import AsyncMock, patch +from types import SimpleNamespace + +import pytest +from fastapi import Request + +from microsoft_agents.hosting.core.authorization import ( + AgentAuthConfiguration, + ClaimsIdentity, +) +from microsoft_agents.hosting.core.http import HttpResponse +from microsoft_agents.hosting.fastapi.jwt_authorization_middleware import ( + JwtAuthorizationMiddleware, + jwt_authorization_decorator, +) + + +def _scope(auth_config: AgentAuthConfiguration, authorization: str | None = None): + headers = [] + if authorization is not None: + headers.append((b"authorization", authorization.encode())) + return { + "type": "http", + "asgi": {"version": "3.0"}, + "method": "GET", + "path": "/", + "raw_path": b"/", + "query_string": b"", + "headers": headers, + "client": ("127.0.0.1", 1234), + "server": ("testserver", 80), + "scheme": "http", + "app": SimpleNamespace( + state=SimpleNamespace(agent_configuration=auth_config) + ), + "state": {}, + } + + +async def _receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + +async def _send_ok(send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + +async def _record_send(messages, message): + messages.append(message) + + +def _status(messages): + return next( + message["status"] + for message in messages + if message["type"] == "http.response.start" + ) + + +@pytest.mark.asyncio +async def test_fastapi_middleware_stores_claims_and_calls_downstream_app(): + auth_config = AgentAuthConfiguration() + claims = ClaimsIdentity({"aud": "app-id"}, True) + messages = [] + downstream_called = False + + async def downstream(scope, receive, send): + nonlocal downstream_called + downstream_called = True + assert scope["state"]["claims_identity"] is claims + await _send_ok(send) + + middleware = JwtAuthorizationMiddleware(downstream) + with patch( + "microsoft_agents.hosting.fastapi.jwt_authorization_middleware._authorize_request", + new=AsyncMock(return_value=claims), + ) as authorize: + scope = _scope(auth_config, "Bearer token") + await middleware(scope, _receive, lambda message: _record_send(messages, message)) + + assert downstream_called is True + assert _status(messages) == 200 + authorize.assert_awaited_once_with("Bearer token", auth_config) + + +@pytest.mark.asyncio +async def test_fastapi_middleware_converts_http_response_without_calling_downstream_app(): + auth_config = AgentAuthConfiguration() + messages = [] + downstream = AsyncMock() + middleware = JwtAuthorizationMiddleware(downstream) + + with patch( + "microsoft_agents.hosting.fastapi.jwt_authorization_middleware._authorize_request", + new=AsyncMock( + return_value=HttpResponse( + body={"error": "Invalid token or authentication failed."}, + status_code=401, + ) + ), + ) as authorize: + await middleware( + _scope(auth_config, "Bearer bad"), + _receive, + lambda message: _record_send(messages, message), + ) + + assert _status(messages) == 401 + downstream.assert_not_awaited() + authorize.assert_awaited_once_with("Bearer bad", auth_config) + + +@pytest.mark.asyncio +async def test_fastapi_decorator_stores_claims_and_calls_handler(): + auth_config = AgentAuthConfiguration() + claims = ClaimsIdentity({"aud": "decorator-app"}, True) + + @jwt_authorization_decorator + async def route(request: Request): + return {"aud": request.state.claims_identity.claims["aud"]} + + with patch( + "microsoft_agents.hosting.fastapi.jwt_authorization_middleware._authorize_request", + new=AsyncMock(return_value=claims), + ) as authorize: + response = await route(Request(_scope(auth_config, "Bearer token"))) + + assert response == {"aud": "decorator-app"} + authorize.assert_awaited_once_with("Bearer token", auth_config) + + +@pytest.mark.asyncio +async def test_fastapi_decorator_converts_http_response(): + auth_config = AgentAuthConfiguration() + + @jwt_authorization_decorator + async def route(request: Request): + return {"called": True} + + with patch( + "microsoft_agents.hosting.fastapi.jwt_authorization_middleware._authorize_request", + new=AsyncMock( + return_value=HttpResponse( + body={"error": "Authorization header not found"}, + status_code=401, + ) + ), + ) as authorize: + response = await route(Request(_scope(auth_config))) + + assert response.status_code == 401 + assert response.body == b'{"error":"Authorization header not found"}' + authorize.assert_awaited_once_with(None, auth_config)