diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index 36efb1a9e..e881ce976 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -85,6 +85,7 @@ steps: echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" fi python -m pip install ./dist/microsoft_agents_hosting_slack*.whl + python -m pip install ./dist/microsoft_agents_hosting_fastapi*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl displayName: 'Install wheels' diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 0b860917d..3c88a7a0c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -74,6 +74,7 @@ jobs: echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" fi python -m pip install ./dist/microsoft_agents_hosting_slack*.whl + python -m pip install ./dist/microsoft_agents_hosting_fastapi*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl - name: Test with pytest diff --git a/dev/integration/pyproject.toml b/dev/integration/pyproject.toml index 0e0ea7111..62d59f43f 100644 --- a/dev/integration/pyproject.toml +++ b/dev/integration/pyproject.toml @@ -5,6 +5,9 @@ requires-python = ">=3.13" dependencies = [ "pytest", "pytest-asyncio", + "microsoft-agents-authentication-msal", + "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/pytest.ini b/dev/integration/pytest.ini index 9908f4bf8..e2b51e7b9 100644 --- a/dev/integration/pytest.ini +++ b/dev/integration/pytest.ini @@ -5,9 +5,11 @@ filterwarnings = ignore::DeprecationWarning ignore::PendingDeprecationWarning ignore::aiohttp.web.NotAppKeyWarning + ignore:Using `httpx` with `starlette\.testclient` is deprecated; install `httpx2` instead\.:starlette.exceptions.StarletteDeprecationWarning:fastapi.testclient # Test discovery configuration testpaths = tests +pythonpath = . python_files = test_*.py *_test.py python_classes = Test* python_functions = test_* 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..46b268000 --- /dev/null +++ b/dev/integration/tests/jwt_validation/_helpers.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import asyncio + +from microsoft_agents.hosting.core.authorization import AgentAuthConfiguration +from microsoft_agents.testing.core.utils import ( + generate_token_from_auth_config, + load_sdk_config_connection, +) + +def clone_auth_config_for_audience( + config: AgentAuthConfiguration, audience: str +) -> AgentAuthConfiguration: + return AgentAuthConfiguration( + auth_type=config.AUTH_TYPE, + client_id=audience, + tenant_id=config.TENANT_ID, + client_secret=config.CLIENT_SECRET, + cert_pfx_file=config.CERT_PFX_FILE, + authority=config.AUTHORITY, + scopes=config.SCOPES, + anonymous_allowed=False, + ) + + +async def acquire_real_service_connection_token() -> tuple[str, AgentAuthConfiguration]: + auth_config = load_sdk_config_connection() + token = await asyncio.to_thread(generate_token_from_auth_config, auth_config) + return token, auth_config + + +def auth_config_with_invalid_audience( + config: AgentAuthConfiguration, +) -> AgentAuthConfiguration: + return clone_auth_config_for_audience(config, f"{config.CLIENT_ID}-invalid") 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..d9de74373 --- /dev/null +++ b/dev/integration/tests/jwt_validation/test_aiohttp_jwt_validation.py @@ -0,0 +1,156 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from pathlib import Path + +import pytest +from dotenv import dotenv_values +from aiohttp import web + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.hosting.aiohttp import ( + jwt_authorization_decorator, + jwt_authorization_middleware, +) +from microsoft_agents.hosting.core.authorization import AgentAuthConfiguration +from microsoft_agents.testing.core.utils import sdk_config_connection + +from tests.utils.pytest import skip_if_no_var +from tests.utils.config import REAL_SERVICE_CONNECTION_ENV_VARS + +from ._helpers import ( + acquire_real_service_connection_token, + auth_config_with_invalid_audience, +) + +_requires_real_service_connection = skip_if_no_var( + *REAL_SERVICE_CONNECTION_ENV_VARS, load_root_env_file=True +) +_JWT_VALIDATION_DIR = Path(__file__).parent +_ANONYMOUS_AUTH_CONFIG = sdk_config_connection( + load_configuration_from_env( + dotenv_values(_JWT_VALIDATION_DIR / "jwt_anonymous.env") + ) +) +_REQUIRED_AUTH_CONFIG = sdk_config_connection( + load_configuration_from_env(dotenv_values(_JWT_VALIDATION_DIR / "jwt_required.env")) +) + + +async def _claims_handler(request): + identity = request["claims_identity"] + return web.json_response( + { + "authenticated": identity.is_authenticated, + "authentication_type": identity.authentication_type, + } + ) + + +def _create_app( + *, use_global_middleware: bool, auth_config: AgentAuthConfiguration +): + middlewares = [jwt_authorization_middleware] if use_global_middleware else [] + app = web.Application(middlewares=middlewares) + app["agent_configuration"] = auth_config + handler = ( + _claims_handler + if use_global_middleware + else jwt_authorization_decorator(_claims_handler) + ) + app.router.add_get("/", handler) + return app + + +@pytest.mark.asyncio +async def test_aiohttp_global_middleware_allows_anonymous_request_from_env_config( + aiohttp_client, +): + app = _create_app(use_global_middleware=True, auth_config=_ANONYMOUS_AUTH_CONFIG) + 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_global_middleware_rejects_invalid_bearer_token(aiohttp_client): + app = _create_app(use_global_middleware=True, auth_config=_REQUIRED_AUTH_CONFIG) + 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." + } + + +@_requires_real_service_connection +@pytest.mark.asyncio +async def test_aiohttp_global_middleware_accepts_real_service_connection_token( + aiohttp_client, +): + token, auth_config = await acquire_real_service_connection_token() + app = web.Application(middlewares=[jwt_authorization_middleware]) + app.agent_configuration = auth_config + app.router.add_get("/", _claims_handler) + client = await aiohttp_client(app) + + response = await client.get("/", headers={"Authorization": f"Bearer {token}"}) + + assert response.status == 200 + assert (await response.json())["authenticated"] is True + + +@_requires_real_service_connection +@pytest.mark.asyncio +async def test_aiohttp_global_middleware_rejects_real_token_with_invalid_audience( + aiohttp_client, +): + token, auth_config = await acquire_real_service_connection_token() + app = web.Application(middlewares=[jwt_authorization_middleware]) + app.agent_configuration = auth_config_with_invalid_audience(auth_config) + app.router.add_get("/", _claims_handler) + client = await aiohttp_client(app) + + response = await client.get("/", headers={"Authorization": f"Bearer {token}"}) + + assert response.status == 401 + assert await response.json() == { + "error": "Invalid token or authentication failed." + } + + +@pytest.mark.asyncio +async def test_aiohttp_decorator_allows_anonymous_request_from_env_config( + aiohttp_client, +): + app = _create_app(use_global_middleware=False, auth_config=_ANONYMOUS_AUTH_CONFIG) + 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_decorator_rejects_invalid_bearer_token(aiohttp_client): + app = _create_app(use_global_middleware=False, auth_config=_REQUIRED_AUTH_CONFIG) + 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..c1ce1133d --- /dev/null +++ b/dev/integration/tests/jwt_validation/test_fastapi_jwt_validation.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from pathlib import Path + +import pytest +from dotenv import dotenv_values +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.hosting.core.authorization import AgentAuthConfiguration +from microsoft_agents.hosting.fastapi import ( + JwtAuthorizationMiddleware, + jwt_authorization_decorator, +) +from microsoft_agents.testing.core.utils import sdk_config_connection + +from tests.utils.config import REAL_SERVICE_CONNECTION_ENV_VARS +from tests.utils.pytest import skip_if_no_var + +from ._helpers import ( + acquire_real_service_connection_token, + auth_config_with_invalid_audience, +) + +_requires_real_service_connection = skip_if_no_var( + *REAL_SERVICE_CONNECTION_ENV_VARS, load_root_env_file=True +) +_JWT_VALIDATION_DIR = Path(__file__).parent +_ANONYMOUS_AUTH_CONFIG = sdk_config_connection( + load_configuration_from_env( + dotenv_values(_JWT_VALIDATION_DIR / "jwt_anonymous.env") + ) +) +_REQUIRED_AUTH_CONFIG = sdk_config_connection( + load_configuration_from_env(dotenv_values(_JWT_VALIDATION_DIR / "jwt_required.env")) +) + + +def _claims_payload(request: Request): + identity = request.state.claims_identity + return { + "authenticated": identity.is_authenticated, + "authentication_type": identity.authentication_type, + } + + +def _create_app( + *, use_global_middleware: bool, auth_config: AgentAuthConfiguration +): + app = FastAPI() + app.state.agent_configuration = auth_config + + if use_global_middleware: + app.add_middleware(JwtAuthorizationMiddleware) + + @app.get("/") + async def handler(request: Request): + return _claims_payload(request) + + else: + + @app.get("/") + @jwt_authorization_decorator + async def handler(request: Request): + return _claims_payload(request) + + return app + + +def test_fastapi_global_middleware_allows_anonymous_request_from_env_config(): + client = TestClient( + _create_app(use_global_middleware=True, auth_config=_ANONYMOUS_AUTH_CONFIG) + ) + + response = client.get("/") + + assert response.status_code == 200 + assert response.json() == { + "authenticated": False, + "authentication_type": "Anonymous", + } + + +def test_fastapi_global_middleware_rejects_invalid_bearer_token(): + client = TestClient( + _create_app(use_global_middleware=True, auth_config=_REQUIRED_AUTH_CONFIG) + ) + + response = client.get("/", headers={"Authorization": "Bearer not-a-jwt"}) + + assert response.status_code == 401 + assert response.json() == {"error": "Invalid token or authentication failed."} + + +@_requires_real_service_connection +@pytest.mark.asyncio +async def test_fastapi_global_middleware_accepts_real_service_connection_token(): + token, auth_config = await acquire_real_service_connection_token() + client = TestClient( + _create_app(use_global_middleware=True, auth_config=auth_config) + ) + + response = client.get("/", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 200 + assert response.json()["authenticated"] is True + + +@_requires_real_service_connection +@pytest.mark.asyncio +async def test_fastapi_global_middleware_rejects_real_token_with_invalid_audience(): + token, auth_config = await acquire_real_service_connection_token() + client = TestClient( + _create_app( + use_global_middleware=True, + auth_config=auth_config_with_invalid_audience(auth_config), + ) + ) + + response = client.get("/", headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == 401 + assert response.json() == {"error": "Invalid token or authentication failed."} + + +def test_fastapi_decorator_allows_anonymous_request_from_env_config(): + client = TestClient( + _create_app(use_global_middleware=False, auth_config=_ANONYMOUS_AUTH_CONFIG) + ) + + response = client.get("/") + + assert response.status_code == 200 + assert response.json() == { + "authenticated": False, + "authentication_type": "Anonymous", + } + + +def test_fastapi_decorator_rejects_invalid_bearer_token(): + client = TestClient( + _create_app(use_global_middleware=False, auth_config=_REQUIRED_AUTH_CONFIG) + ) + + response = client.get("/", headers={"Authorization": "Bearer not-a-jwt"}) + + assert response.status_code == 401 + assert response.json() == {"error": "Invalid token or authentication failed."} diff --git a/dev/integration/tests/utils/__init__.py b/dev/integration/tests/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/dev/integration/tests/utils/config.py b/dev/integration/tests/utils/config.py new file mode 100644 index 000000000..4a82268aa --- /dev/null +++ b/dev/integration/tests/utils/config.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +REAL_SERVICE_CONNECTION_ENV_VARS = ( + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID", +) \ No newline at end of file diff --git a/dev/integration/tests/utils/pytest.py b/dev/integration/tests/utils/pytest.py new file mode 100644 index 000000000..c419524c3 --- /dev/null +++ b/dev/integration/tests/utils/pytest.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +import pytest +from dotenv import dotenv_values + +def skip_if_no_var(*env_vars: str, environ: dict | None = None, load_root_env_file: bool = False): + """Skip the test if any of the specified environment variables are not set. + + :param env_vars: The environment variable names to check. + :param environ: Optional dictionary representing the environment variables. Defaults to os.environ. + :return: A pytest mark to skip the test if any environment variable is not set. + """ + if load_root_env_file: + # Load environment variables from the root .env file if specified + environ = {**os.environ, **dotenv_values(".env")} + return pytest.mark.skipif( + any(env_var not in (environ or os.environ) for env_var in env_vars), + reason=f"Skipping test because one or more environment variables are not set: {', '.join(env_vars)}" + ) \ No newline at end of file diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/activity_handler_scenario.py b/dev/microsoft-agents-testing/microsoft_agents/testing/activity_handler_scenario.py index 8c405c7cb..fac3d2b13 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/activity_handler_scenario.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/activity_handler_scenario.py @@ -23,6 +23,9 @@ UserState, MemoryStorage, Storage, + ConnectionManager, + AgentAuthConfiguration, + AnonymousTokenProvider, ) from microsoft_agents.hosting.core.authorization import ClaimsIdentity from microsoft_agents.hosting.aiohttp import CloudAdapter @@ -107,7 +110,16 @@ async def _setup(self) -> None: storage = MemoryStorage() conv_state = ConversationState(storage) user_state = UserState(storage) - adapter = CloudAdapter() + adapter = CloudAdapter( + connection_manager = ConnectionManager( + provider_factory=lambda c: AnonymousTokenProvider(), + connections_configurations={ + "SERVICE_CONNECTION": AgentAuthConfiguration( + anonymous_allowed=True, + ) + } + ) + ) result = self._create_handler(conv_state, user_state, storage) if hasattr(result, "__await__"): diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py index 59b0eadd1..342f4cca4 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/transport/aiohttp_callback_server.py @@ -112,5 +112,6 @@ async def _handle_request(self, request: Request) -> Response: exchange = Exchange(error=str(e), response_at=response_at) response = Response(status=500, text="An internal error has occurred.") - self._transcript.record(exchange) + if self._transcript is not None: + self._transcript.record(exchange) return response diff --git a/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py b/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py index ebdd5fb7e..fa6e73487 100644 --- a/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py +++ b/dev/microsoft-agents-testing/microsoft_agents/testing/core/utils.py @@ -9,7 +9,11 @@ import requests -from microsoft_agents.activity import Activity +from pathlib import Path + +from dotenv import dotenv_values + +from microsoft_agents.activity import Activity, load_configuration_from_env from microsoft_agents.hosting.core import AgentAuthConfiguration from .transport import Exchange @@ -45,6 +49,17 @@ def sdk_config_connection( data = sdk_config["CONNECTIONS"][connection_name]["SETTINGS"] return AgentAuthConfiguration(**data) +def load_sdk_config_connection(connection_name: str = "SERVICE_CONNECTION", env_path: str | Path = ".env") -> AgentAuthConfiguration: + """Load an AgentAuthConfiguration from a .env file. + + :param connection_name: The connection name to look up in the .env file. + :param env_path: Path to the .env file (default is ".env"). + :return: An AgentAuthConfiguration instance. + """ + raw_sdk_config = dotenv_values(str(env_path)) + raw_sdk_config = { k: v for k, v in raw_sdk_config.items() if not k.startswith("LOGGING")} + sdk_config = load_configuration_from_env(raw_sdk_config) + return sdk_config_connection(sdk_config, connection_name) # TODO: Use MsalAuth to generate token instead of raw HTTP requests # TODO: Support other forms of auth (certificates, managed identity, etc.) @@ -98,3 +113,18 @@ def generate_token_from_config( if not client_id or not client_secret or not tenant_id: raise ValueError("Incorrect configuration provided for token generation.") return generate_token(client_id, client_secret, tenant_id) + +def generate_token_from_auth_config(auth_config: AgentAuthConfiguration) -> str: + """Generates a token using a provided AgentAuthConfiguration. + + :param auth_config: An instance of AgentAuthConfiguration containing connection settings. + :return: Generated access token as a string. + """ + + client_id = auth_config.CLIENT_ID + client_secret = auth_config.CLIENT_SECRET + tenant_id = auth_config.TENANT_ID + + if not client_id or not client_secret or not tenant_id: + raise ValueError("Incorrect configuration provided for token generation.") + return generate_token(client_id, client_secret, tenant_id) \ No newline at end of file diff --git a/dev_dependencies.txt b/dev_dependencies.txt index 86b3d4c82..abd815599 100644 --- a/dev_dependencies.txt +++ b/dev_dependencies.txt @@ -1,5 +1,6 @@ pytest pytest-asyncio +pytest-aiohttp pytest-mock pre-commit click \ No newline at end of file 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..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 @@ -1,13 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from typing import Optional 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-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..b2237c733 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,48 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + import functools + +from typing import cast + from aiohttp.web import Request, middleware, json_response -from microsoft_agents.hosting.core.authorization import ( - AgentAuthConfiguration, - JwtTokenValidator, -) +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. + """ + auth_config = cast( + AgentAuthConfiguration | None, request.app.get("agent_configuration", None) + ) - auth_config: AgentAuthConfiguration = request.app["agent_configuration"] - token_validator = JwtTokenValidator(auth_config) auth_header = request.headers.get("Authorization") + res = await _authorize_request(auth_header, auth_config) - 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 - ) + 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..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 @@ -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/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/__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..9e0a692d0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py @@ -0,0 +1,59 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging + +from jwt import PyJWTError + +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. + + :param authorization_header: The value of the Authorization header from the request. + :param auth_config: The AgentAuthConfiguration instance containing authentication settings. + :return: A ClaimsIdentity object if the token is valid, or an HttpResponse with an error message and status code if the token is invalid or missing. + """ + + 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 (PyJWTError, 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-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/__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..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 @@ -1,14 +1,14 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from typing import Optional 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 83b3fceb1..ad2207635 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,13 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import functools +import inspect + 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, - JwtTokenValidator, -) -logger = logging.getLogger(__name__) +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 class JwtAuthorizationMiddleware: @@ -30,45 +34,48 @@ 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 ) 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(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) + + +def jwt_authorization_decorator(func): + """ + :param func: The FastAPI route handler function to be decorated. + :return: The decorated FastAPI route handler function. + """ + + @functools.wraps(func) + async def wrapper(request: Request, *args, **kwargs): + 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 + ) + + auth_header = request.headers.get("Authorization") + + res = await _authorize_request(auth_header, auth_config) + if isinstance(res, HttpResponse): + return JSONResponse(content=res.body, status_code=res.status_code) + request.state.claims_identity = res + return await func(request, *args, **kwargs) + + # FastAPI relies on inspect.signature for dependency injection and docs. + wrapper.__signature__ = inspect.signature(func) # type: ignore[attr-defined] + + return wrapper diff --git a/test_samples/fastapi/authorization_agent.py b/test_samples/fastapi/authorization_agent.py index 81c8bf1c5..8347a3fad 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 @@ -50,7 +50,7 @@ @AGENT_APP.message(re.compile(r"^/(status|auth status|check status)", re.IGNORECASE)) -async def status(context: TurnContext, state: TurnState) -> bool: +async def status(context: TurnContext, state: TurnState): """ Internal method to check authorization status for all configured handlers. Returns True if at least one handler has a valid token. @@ -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, ): 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) 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..6cfb6d002 --- /dev/null +++ b/tests/hosting_aiohttp/test_jwt_authorization_middleware.py @@ -0,0 +1,138 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import importlib +import json +from unittest.mock import AsyncMock, patch + +import pytest +from aiohttp import web + +from microsoft_agents.hosting.aiohttp.jwt_authorization_middleware import ( + jwt_authorization_decorator, +) +from microsoft_agents.hosting.core.authorization import ( + AgentAuthConfiguration, + ClaimsIdentity, +) +from microsoft_agents.hosting.core.http import HttpResponse + +_jwt_middleware_module = importlib.import_module( + "microsoft_agents.hosting.aiohttp.jwt_authorization_middleware" +) + + +class _RequestStub: + def __init__( + self, auth_config: AgentAuthConfiguration, authorization: str | None = None + ): + self.app = {"agent_configuration": auth_config} + self.headers = {} + if authorization is not None: + self.headers["Authorization"] = authorization + self._items = {} + + def __getitem__(self, key): + return self._items[key] + + def __setitem__(self, key, value): + self._items[key] = value + + +def _response_json(response): + return json.loads(response.body.decode()) + + +@pytest.mark.asyncio +async def test_aiohttp_middleware_stores_claims_and_calls_handler(): + auth_config = AgentAuthConfiguration() + claims = ClaimsIdentity({"aud": "app-id"}, True) + + async def handler(request): + return web.json_response({"aud": request["claims_identity"].claims["aud"]}) + + with patch.object( + _jwt_middleware_module, + "_authorize_request", + new=AsyncMock(return_value=claims), + ) as authorize: + response = await _jwt_middleware_module._jwt_authorization_middleware( + _RequestStub(auth_config, "Bearer token"), handler + ) + + assert response.status == 200 + assert _response_json(response) == {"aud": "app-id"} + authorize.assert_awaited_once_with("Bearer token", auth_config) + + +@pytest.mark.asyncio +async def test_aiohttp_middleware_converts_http_response(): + auth_config = AgentAuthConfiguration() + handler = AsyncMock(return_value=web.json_response({"called": True})) + + with patch.object( + _jwt_middleware_module, + "_authorize_request", + new=AsyncMock( + return_value=HttpResponse( + body={"error": "Invalid token or authentication failed."}, + status_code=401, + ) + ), + ) as authorize: + response = await _jwt_middleware_module._jwt_authorization_middleware( + _RequestStub(auth_config, "Bearer token"), handler + ) + + assert response.status == 401 + assert _response_json(response) == { + "error": "Invalid token or authentication failed." + } + handler.assert_not_awaited() + authorize.assert_awaited_once_with("Bearer token", auth_config) + + +@pytest.mark.asyncio +async def test_aiohttp_decorator_uses_authorization_helper(): + 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"]}) + + with patch.object( + _jwt_middleware_module, + "_authorize_request", + new=AsyncMock(return_value=claims), + ) as authorize: + response = await handler(_RequestStub(auth_config, "Bearer token")) + + assert response.status == 200 + assert _response_json(response) == {"aud": "decorator-app"} + authorize.assert_awaited_once_with("Bearer token", auth_config) + + +@pytest.mark.asyncio +async def test_aiohttp_decorator_converts_http_response(): + auth_config = AgentAuthConfiguration() + + @jwt_authorization_decorator + async def handler(request): + return web.json_response({"called": True}) + + with patch.object( + _jwt_middleware_module, + "_authorize_request", + new=AsyncMock( + return_value=HttpResponse( + body={"error": "Authorization header not found"}, + status_code=401, + ) + ), + ) as authorize: + response = await handler(_RequestStub(auth_config)) + + assert response.status == 401 + assert _response_json(response) == {"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..2d8269788 --- /dev/null +++ b/tests/hosting_core/authorization/test_authorize_request.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import importlib +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 + +_authorize_request_module = importlib.import_module( + "microsoft_agents.hosting.core.authorization.jwt._authorize_request" +) + + +@pytest.mark.asyncio +async def test_authorize_request_returns_500_when_config_is_missing(): + with patch.object(_authorize_request_module, "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.object( + _authorize_request_module, + "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.object( + _authorize_request_module, + "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.object( + _authorize_request_module, + "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..79c06320e --- /dev/null +++ b/tests/hosting_fastapi/test_jwt_authorization_middleware.py @@ -0,0 +1,205 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import inspect +from unittest.mock import AsyncMock, patch +from types import SimpleNamespace + +import pytest +from fastapi import Depends, 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) + + +def test_fastapi_decorator_preserves_route_signature(): + async def route( + request: Request, + conversation_id: str, + include_history: bool = False, + ) -> dict: + return {} + + decorated = jwt_authorization_decorator(route) + + assert inspect.signature(decorated, follow_wrapped=False) == inspect.signature( + route + ) + + +def test_fastapi_decorator_preserves_route_signature_with_dependencies(): + def get_user_id() -> str: + return "user-id" + + async def route( + request: Request, + user_id: str = Depends(get_user_id), + ) -> dict: + return {} + + decorated = jwt_authorization_decorator(route) + + assert inspect.signature(decorated, follow_wrapped=False) == inspect.signature( + route + ) + + +def test_fastapi_decorator_does_not_change_original_route_signature(): + async def route( + request: Request, + conversation_id: str, + user_id: str = Depends(lambda: "user-id"), + ) -> dict: + return {} + + original_signature = inspect.signature(route) + + jwt_authorization_decorator(route) + + assert inspect.signature(route) == original_signature