From 6aa0cafa2b36c6dedb0aac66bbdfac7993ef1abe Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 27 Oct 2026 15:43:03 -0700 Subject: [PATCH 1/9] Caching of TokenValidator for jwt middleware --- .../aiohttp/jwt_authorization_middleware.py | 10 +++--- .../authorization/jwt/_authorize_request.py | 36 ++++++++++++++++--- .../fastapi/jwt_authorization_middleware.py | 8 ++--- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py b/libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py index c2ba89017..153e45cdb 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 @@ -35,10 +35,6 @@ 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_header = request.headers.get("Authorization") if auth_header is not None: # aiohttp-specific tolerance: trailing whitespace (spaces/tabs) after @@ -52,7 +48,11 @@ async def _jwt_authorization_middleware(request: Request, handler): return json_response(_GENERIC_AUTH_ERROR, status=401) auth_header = f"Bearer {token}" - res = await _authorize_request(auth_header, auth_config) + res = await _authorize_request( + auth_header, + request.app.get("agent_configuration"), + cache_key=request.app, + ) if isinstance(res, HttpResponse): return json_response(res.body, status=res.status_code) 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 a402d8364..4e499d636 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 @@ -4,7 +4,10 @@ import logging from jwt import PyJWTError +from typing import Any, Mapping +from weakref import WeakKeyDictionary +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core.http import HttpResponse from ..agent_auth_configuration import AgentAuthConfiguration @@ -13,9 +16,31 @@ logger = logging.getLogger(__name__) +_TOKEN_VALIDATION_CACHE: WeakKeyDictionary[Any, JwtTokenValidator] = WeakKeyDictionary() + +def _resolve_token_validator( + auth_config: Mapping | AgentAuthConfiguration | None, + cache_key: Any = None, +) -> JwtTokenValidator | None: + + if cache_key is not None and cache_key in _TOKEN_VALIDATION_CACHE: + return _TOKEN_VALIDATION_CACHE[cache_key] + + if auth_config is not None: + config: Configuration | AgentAuthConfiguration = auth_config + if not isinstance(auth_config, AgentAuthConfiguration): + config = Configuration(auth_config) + validator = JwtTokenValidator(config) + if cache_key is not None: + _TOKEN_VALIDATION_CACHE[cache_key] = validator + return validator + return None async def _authorize_request( - authorization_header: str | None, auth_config: AgentAuthConfiguration | None + authorization_header: str | None, + auth_config: Mapping | AgentAuthConfiguration | None, + *, + cache_key: Any = None ) -> ClaimsIdentity | HttpResponse: """ Authorizes a request based on the provided JWT token in the Authorization header. @@ -25,16 +50,19 @@ async def _authorize_request( :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. """ + validator = _resolve_token_validator( + auth_config, + cache_key + ) - if auth_config is None: + if not validator: 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: + if auth_config.config.anonymous_allowed: claims_identity = validator.get_anonymous_claims() return claims_identity return HttpResponse( 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 ad22537e4..6a68da65a 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 @@ -32,14 +32,12 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): return app = scope.get("app") - state = getattr(app, "state", None) if app else None - auth_config: AgentAuthConfiguration | None = getattr( - state, "agent_configuration", None - ) request = Request(scope, receive=receive) res = await _authorize_request( - request.headers.get("Authorization"), auth_config + request.headers.get("Authorization"), + getattr(app.state, "agent_configuration", None), + cache_key=app ) if isinstance(res, HttpResponse): From 15db5ba602cad69f62a9fe0c9ed6553f27478462 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 28 Oct 2026 15:30:15 -0700 Subject: [PATCH 2/9] Porting jwt config changes --- .../authorization/connection_settings_base.py | 84 +++++++++++++++++ .../core/authorization/jwt/__init__.py | 4 + .../authorization/jwt/_authorize_request.py | 8 +- .../jwt/jwt_connection_settings.py | 46 +++++++++ .../authorization/jwt/jwt_token_validator.py | 94 +++++++++++++------ .../jwt/token_validation_configuration.py | 70 ++++++++++++++ 6 files changed, 273 insertions(+), 33 deletions(-) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_connection_settings.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_settings_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_settings_base.py index b1e36f6a0..29ec97b36 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_settings_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_settings_base.py @@ -4,7 +4,11 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass, field, fields from typing import Any, TYPE_CHECKING +from typing_extensions import Self + +from microsoft_agents.activity.config import Configuration, _Configurable, xpath if TYPE_CHECKING: from .agent_auth_configuration import AgentAuthConfiguration @@ -109,3 +113,83 @@ def from_configuration( ) -> "_ConnectionSettingsBase": """Build base settings from an :class:`AgentAuthConfiguration`.""" return cls(**cls.base_kwargs_from_configuration(configuration)) + + +_KNOWN_KWARGS_CACHE: dict[type[ConnectionSettingsBase], list[str]] = {} + + +@dataclass(frozen=True) +class ConnectionSettingsBase(_Configurable): + """Provider-independent settings bound from Configuration. + + Frozen fields cannot be reassigned, but scopes and extras remain mutable. + Treat those containers as read-only after handing settings to a provider. + """ + + client_id: str | None = None + authority: str | None = None + tenant_id: str | None = None + alternate_blueprint_connection_name: str | None = None + scopes: list[str] = field(default_factory=list) + _extras: Configuration = field(default_factory=Configuration) + + @property + def extras(self) -> Configuration: + """Return unrecognized provider settings, not an immutable copy.""" + return self._extras + + @classmethod + def _from_agent_auth_config(cls, agent_auth_config: AgentAuthConfiguration) -> Self: + attrs = dict(vars(agent_auth_config)) + attrs.update(attrs.pop("provider_settings", {})) + attrs["ISSUERS"] = agent_auth_config.ISSUERS + return cls.from_mapping(attrs) + + @classmethod + def _get_init_kwargs(cls, config: Configuration) -> dict: + return { + "client_id": config.get_strn(cls._xpath("client_id")), + "authority": config.get_strn( + cls._xpath("authority"), cls._xpath("authorityendpoint") + ), + "tenant_id": config.get_strn(cls._xpath("tenant_id")), + "scopes": config.get_scopes(cls._xpath("scopes")), + "alternate_blueprint_connection_name": config.get_strn( + "alt_blueprint_name", + "ALT_BLUEPRINT_NAME", + "alt_blueprint_id", + "ALT_BLUEPRINT_ID", + *cls._xpath("alternate_blueprint_connection_name"), + ), + } + + @classmethod + def _known_kwargs(cls) -> list[str]: + if cls not in _KNOWN_KWARGS_CACHE: + _KNOWN_KWARGS_CACHE[cls] = [ + alias + for f in fields(cls) + if f.init and f.name != "_extras" + for alias in xpath(f.name) + ] + return _KNOWN_KWARGS_CACHE[cls] + + @classmethod + def _get_extras(cls, config: Configuration) -> Configuration: + known_kwargs = cls._known_kwargs() + base = {k: v for k, v in config.items() if k not in known_kwargs} + return Configuration.from_mapping( + base, _base_path=config._base_path, **base.pop("extras", {}) + ) + + @classmethod + def _from_configuration(cls, config: Configuration) -> Self: + try: + settings = config.get_section(xpath("settings")) + except KeyError: + settings = config + return cls(**cls._get_init_kwargs(settings), _extras=cls._get_extras(settings)) + + @staticmethod + def _xpath(name: str) -> list[str]: + return xpath(name, lower_snake=True) 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 index 316cae4fa..22a5c5652 100644 --- 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 @@ -3,8 +3,12 @@ from ._authorize_request import _authorize_request from .jwt_token_validator import JwtTokenValidator +from .jwt_connection_settings import JwtConnectionSettings +from .token_validation_configuration import TokenValidationConfiguration __all__ = [ "JwtTokenValidator", "_authorize_request", + "JwtConnectionSettings", + "TokenValidationConfiguration", ] 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 4e499d636..6eceda18f 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 @@ -19,7 +19,7 @@ _TOKEN_VALIDATION_CACHE: WeakKeyDictionary[Any, JwtTokenValidator] = WeakKeyDictionary() def _resolve_token_validator( - auth_config: Mapping | AgentAuthConfiguration | None, + auth_config: Configuration | AgentAuthConfiguration | None, cache_key: Any = None, ) -> JwtTokenValidator | None: @@ -27,7 +27,7 @@ def _resolve_token_validator( return _TOKEN_VALIDATION_CACHE[cache_key] if auth_config is not None: - config: Configuration | AgentAuthConfiguration = auth_config + config: AgentAuthConfiguration | Configuration = auth_config if not isinstance(auth_config, AgentAuthConfiguration): config = Configuration(auth_config) validator = JwtTokenValidator(config) @@ -38,7 +38,7 @@ def _resolve_token_validator( async def _authorize_request( authorization_header: str | None, - auth_config: Mapping | AgentAuthConfiguration | None, + auth_config: Configuration | AgentAuthConfiguration | None, *, cache_key: Any = None ) -> ClaimsIdentity | HttpResponse: @@ -62,7 +62,7 @@ async def _authorize_request( ) if not authorization_header: - if auth_config.config.anonymous_allowed: + if validator.config.anonymous_allowed: claims_identity = validator.get_anonymous_claims() return claims_identity return HttpResponse( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_connection_settings.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_connection_settings.py new file mode 100644 index 000000000..569063e5a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_connection_settings.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field + +from microsoft_agents.activity.config import Configuration, xpath + +from .._entra_issuers import default_connection_issuers +from ..connection_settings_base import ConnectionSettingsBase + + +@dataclass(frozen=True) +class JwtConnectionSettings(ConnectionSettingsBase): + """Provider-independent connection settings for inbound JWT validation.""" + + validate_issuer: bool = False + issuers: list[str] = field(default_factory=list) + anonymous_allowed: bool = False + + @classmethod + def _get_init_kwargs(cls, config: Configuration) -> dict: + base = super()._get_init_kwargs(config) + raw_issuers = config.get(xpath("issuers")) + issuers = ( + Configuration(issuers=list(raw_issuers.values())).get_str_list("issuers") + if isinstance(raw_issuers, Mapping) + else config.get_str_list(xpath("issuers")) + ) + issuers = issuers or default_connection_issuers( + base.get("tenant_id"), base.get("authority") + ) + base.update( + { + "validate_issuer": config.get_bool( + xpath("validate_issuer"), default=False + ), + "issuers": issuers, + "anonymous_allowed": config.get_bool( + xpath("anonymous_allowed"), default=False + ), + } + ) + return base diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_token_validator.py index 00265a9e6..720594f32 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_token_validator.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_token_validator.py @@ -9,6 +9,8 @@ from jwt import PyJWKClient, PyJWK, decode, get_unverified_header +from microsoft_agents.activity.config import Configuration + from ..agent_auth_configuration import AgentAuthConfiguration from ..authentication_constants import AuthenticationConstants from ..claims_identity import ClaimsIdentity @@ -20,6 +22,8 @@ jwks_login_host, well_known_first_party_issuers, ) +from .token_validation_configuration import TokenValidationConfiguration +from .jwt_connection_settings import JwtConnectionSettings logger = logging.getLogger(__name__) @@ -75,12 +79,39 @@ class JwtTokenValidator: _jwk_client_manager = _JwkClientManager() - def __init__(self, configuration: AgentAuthConfiguration): + def __init__(self, configuration: AgentAuthConfiguration | Configuration): """Initializes the JwtTokenValidator with the given configuration. - :param configuration: An instance of AgentAuthConfiguration containing the necessary settings for token validation. + :param configuration: Application Configuration used to bind + TokenValidationConfiguration. AgentAuthConfiguration is accepted + through a legacy conversion path. """ - self.configuration = configuration + if isinstance(configuration, AgentAuthConfiguration): + self._config = TokenValidationConfiguration( + tenant_id=configuration.tenant_id, + anonymous_allowed=configuration.ANONYMOUS_ALLOWED, + audiences=[ + conn.CLIENT_ID + for conn in configuration._connections.values() + if conn.CLIENT_ID + ], + connections={ + name: JwtConnectionSettings._from_agent_auth_config(conn) + for name, conn in configuration._connections.items() + }, + default_jwt_settings=JwtConnectionSettings._from_agent_auth_config( + configuration + ), + ) + else: + self._config = TokenValidationConfiguration.from_configuration( + configuration + ) + + @property + def config(self) -> TokenValidationConfiguration: + """Return the bound token-validation policy and connection settings.""" + return self._config async def validate_token(self, token: str) -> ClaimsIdentity: """Validates a JWT token. @@ -106,15 +137,17 @@ async def validate_token(self, token: str) -> ClaimsIdentity: # root-configuration endpoint for backward-compatible network egress. # This is routing only -- final acceptance is checked against the # signature-verified claims below. - routing_config = ( - self.configuration._jwt_patch_find_connection( - unverified_payload.get(AuthenticationConstants.AUDIENCE_CLAIM) - ) - or self.configuration + routing_config = self._config.get_connection_settings( + unverified_payload.get(AuthenticationConstants.AUDIENCE_CLAIM) ) + if not routing_config: + logger.warning( + "No default or specific routing configuration found for the given audience." + ) + raise ValueError("Invalid audience.") jwks_uri = _build_jwks_uri( unverified_payload.get(AuthenticationConstants.ISSUER_CLAIM), - self.configuration, + self.config, routing_config, ) key = await self._jwk_client_manager.get_signing_key(jwks_uri, header) @@ -128,23 +161,26 @@ async def validate_token(self, token: str) -> ClaimsIdentity: ) aud = decoded_token.get(AuthenticationConstants.AUDIENCE_CLAIM, "") - if not self.configuration._jwt_patch_is_valid_aud(aud): + if not self._config.validate_audience(aud): logger.warning("JWT audience not accepted.") raise ValueError("Invalid audience.") - matched_config = ( - self.configuration._jwt_patch_find_connection(aud) or routing_config - ) + matched_config = self._config.get_connection_settings(aud) + if not matched_config: + logger.warning( + "No default or specific routing configuration found for the given audience." + ) + raise ValueError("Invalid audience.") # Issuer allow-list validation is explicit opt-in - # (AgentAuthConfiguration.VALIDATE_ISSUER) to preserve backward + # (JwtConnectionSettings.validate_issuer) to preserve backward # compatibility for existing deployments. Tid-to-issuer binding, # however, is always enforced per issue #626: it only engages when # the (verified) issuer is itself a recognized Entra issuer carrying # a GUID tenant, so Bot Framework/non-Entra and tenant-alias issuers # are unaffected, and a missing ``tid`` claim skips the check rather # than failing closed. - if matched_config.VALIDATE_ISSUER: + if matched_config.validate_issuer: _validate_issuer( decoded_token.get(AuthenticationConstants.ISSUER_CLAIM), matched_config ) @@ -164,8 +200,8 @@ def get_anonymous_claims(self) -> ClaimsIdentity: def _build_jwks_uri( iss: Any, - root_config: AgentAuthConfiguration, - routing_config: AgentAuthConfiguration, + root_config: TokenValidationConfiguration, + routing_config: JwtConnectionSettings, ) -> str: """Builds the JWKS URI for the (unverified, routing-only) issuer and the root and audience-selected connection configurations. @@ -186,29 +222,29 @@ def _build_jwks_uri( if bf_uri: return bf_uri - if is_gov_authority(routing_config.AUTHORITY): - host = jwks_login_host(routing_config.AUTHORITY) + if is_gov_authority(routing_config.authority): + host = jwks_login_host(routing_config.authority) tenant = ( - effective_tenant(routing_config.TENANT_ID, routing_config.AUTHORITY) + effective_tenant(routing_config.tenant_id, routing_config.authority) or "common" ) return f"{host}/{tenant}/discovery/v2.0/keys" return ( "https://login.microsoftonline.com/" - f"{root_config.TENANT_ID or 'common'}/discovery/v2.0/keys" + f"{root_config.tenant_id or 'common'}/discovery/v2.0/keys" ) -def _get_valid_issuers(config: AgentAuthConfiguration) -> set[str]: +def _get_valid_issuers(config: JwtConnectionSettings) -> set[str]: """Case-insensitive union of the connection's configured/default issuers - (``AgentAuthConfiguration.ISSUERS``) and the always-trusted Microsoft + (``JwtConnectionSettings.issuers``) and the always-trusted Microsoft first-party issuers for the connection's cloud.""" - combined = list(config.ISSUERS) + well_known_first_party_issuers(config.AUTHORITY) + combined = list(config.issuers) + well_known_first_party_issuers(config.authority) return {issuer.lower() for issuer in combined} -def _is_multi_tenant(config: AgentAuthConfiguration) -> bool: +def _is_multi_tenant(config: JwtConnectionSettings) -> bool: """A connection configured for the Entra ``common``/``organizations`` meta-tenant ("blueprint" agent) has no single known calling tenant at configuration time, so :func:`_is_acceptable_tenant_issuer` is used instead @@ -221,11 +257,11 @@ def _is_multi_tenant(config: AgentAuthConfiguration) -> bool: AUTHORITY embeds ``common``/``organizations`` (rather than TENANT_ID) is still recognized as multi-tenant. """ - tenant = (effective_tenant(config.TENANT_ID, config.AUTHORITY) or "").lower() + tenant = (effective_tenant(config.tenant_id, config.authority) or "").lower() return tenant in ("common", "organizations") -def _is_acceptable_tenant_issuer(iss: str, config: AgentAuthConfiguration) -> bool: +def _is_acceptable_tenant_issuer(iss: str, config: JwtConnectionSettings) -> bool: """Whether ``iss`` is a canonical Entra issuer acceptable for a multi-tenant connection: it must carry a tenant GUID and, for cloud-specific v2 issuers, match the connection's cloud (public vs US Government). The cloud-agnostic @@ -233,10 +269,10 @@ def _is_acceptable_tenant_issuer(iss: str, config: AgentAuthConfiguration) -> bo info = entra_issuer_info(iss) if info is None: return False - return info.gov is None or info.gov == is_gov_authority(config.AUTHORITY) + return info.gov is None or info.gov == is_gov_authority(config.authority) -def _validate_issuer(iss: Any, config: AgentAuthConfiguration) -> None: +def _validate_issuer(iss: Any, config: JwtConnectionSettings) -> None: """Validates that the token's ``iss`` claim is accepted for the matched connection: either present in the connection's issuer allow-list, or, for a multi-tenant (``common``/``organizations``) connection, a canonical diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py new file mode 100644 index 000000000..0a4f9937c --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from microsoft_agents.activity.config import Configuration, _Configurable, xpath + +from .jwt_connection_settings import JwtConnectionSettings + + +@dataclass(frozen=True) +class TokenValidationConfiguration(_Configurable): + tenant_id: str | None = None + audiences: list[str] = field(default_factory=list) + anonymous_allowed: bool = False + default_jwt_settings: JwtConnectionSettings | None = None + connections: dict[str, JwtConnectionSettings] = field(default_factory=dict) + + @classmethod + def _from_configuration( + cls, configuration: Configuration + ) -> TokenValidationConfiguration: + token_validation = configuration.get_section( + xpath("token_validation"), default=Configuration() + ) + audiences = token_validation.collect_str("audiences::*", "AUDIENCES::*") + tenant_id = token_validation.get_strn(xpath("tenant_id")) + anonymous_allowed = token_validation.get_booln(xpath("anonymous_allowed")) + + connections: dict[str, JwtConnectionSettings] = {} + for name, conf in configuration.get_items(xpath("connections")): + connections[name] = JwtConnectionSettings.from_mapping(conf) + + update_audiences = not audiences + update_tenant_id = not tenant_id + default_jwt_settings: JwtConnectionSettings | None = None + for conn_name, conn in connections.items(): + if conn_name.upper() == "SERVICE_CONNECTION": + if update_tenant_id: + tenant_id = conn.tenant_id + default_jwt_settings = conn + if update_audiences and conn.client_id: + audiences.append(conn.client_id) + + if default_jwt_settings is not None and anonymous_allowed is None: + anonymous_allowed = default_jwt_settings.anonymous_allowed + + return cls( + tenant_id=tenant_id, + audiences=audiences, + connections=connections, + default_jwt_settings=default_jwt_settings, + anonymous_allowed=anonymous_allowed or False, + ) + + def validate_audience(self, aud: Any) -> bool: + if not aud or not isinstance(aud, str): + return False + return any(aud.lower() == target.lower() for target in self.audiences) + + def get_connection_settings(self, aud: Any) -> JwtConnectionSettings | None: + if not aud or not isinstance(aud, str): + return None + for conn in self.connections.values(): + if conn.client_id and conn.client_id.lower() == aud.lower(): + return conn + return self.default_jwt_settings From 21aaf64d2c0fde702d0e555a8d7b861ab5788c8c Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 29 Oct 2026 19:11:15 -0700 Subject: [PATCH 3/9] Improving tests --- .../jwt/jwt_connection_settings.py | 8 +- .../jwt/token_validation_configuration.py | 9 +- .../authorization/test_authorize_request.py | 76 +++++ .../test_jwt_connection_settings.py | 106 +++++++ .../test_token_validation_configuration.py | 270 ++++++++++++++++++ 5 files changed, 460 insertions(+), 9 deletions(-) create mode 100644 tests/hosting_core/authorization/test_jwt_connection_settings.py create mode 100644 tests/hosting_core/authorization/test_token_validation_configuration.py diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_connection_settings.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_connection_settings.py index 569063e5a..fa512f91d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_connection_settings.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_connection_settings.py @@ -3,7 +3,6 @@ from __future__ import annotations -from collections.abc import Mapping from dataclasses import dataclass, field from microsoft_agents.activity.config import Configuration, xpath @@ -23,12 +22,7 @@ class JwtConnectionSettings(ConnectionSettingsBase): @classmethod def _get_init_kwargs(cls, config: Configuration) -> dict: base = super()._get_init_kwargs(config) - raw_issuers = config.get(xpath("issuers")) - issuers = ( - Configuration(issuers=list(raw_issuers.values())).get_str_list("issuers") - if isinstance(raw_issuers, Mapping) - else config.get_str_list(xpath("issuers")) - ) + issuers = config.get_str_list(xpath("issuers")) issuers = issuers or default_connection_issuers( base.get("tenant_id"), base.get("authority") ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py index 0a4f9937c..ade7594b3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py @@ -31,8 +31,13 @@ def _from_configuration( anonymous_allowed = token_validation.get_booln(xpath("anonymous_allowed")) connections: dict[str, JwtConnectionSettings] = {} - for name, conf in configuration.get_items(xpath("connections")): - connections[name] = JwtConnectionSettings.from_mapping(conf) + connection_config = configuration.get_section( + xpath("connections"), default=Configuration() + ) + for name in connection_config: + connections[name] = JwtConnectionSettings.from_configuration( + connection_config.get_section(name) + ) update_audiences = not audiences update_tenant_id = not tenant_id diff --git a/tests/hosting_core/authorization/test_authorize_request.py b/tests/hosting_core/authorization/test_authorize_request.py index f997b775a..8fc7f5b72 100644 --- a/tests/hosting_core/authorization/test_authorize_request.py +++ b/tests/hosting_core/authorization/test_authorize_request.py @@ -3,15 +3,18 @@ import importlib from unittest.mock import AsyncMock, MagicMock, patch +from weakref import ref import pytest +from microsoft_agents.activity.config import Configuration 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 +from tests._common.jwt_test_utils import generate_rsa_keypair, make_signed_jwt _authorize_request_module = importlib.import_module( "microsoft_agents.hosting.core.authorization.jwt._authorize_request" @@ -85,6 +88,79 @@ async def test_authorize_request_validates_bearer_token(): validator.validate_token.assert_awaited_once_with("token-value") +@pytest.mark.asyncio +@pytest.mark.parametrize("anonymous_allowed", [True, False]) +async def test_root_configuration_anonymous_policy_overrides_service_connection( + anonymous_allowed, +): + config = Configuration( + connections={ + "SERVICE_CONNECTION": { + "settings": {"anonymous_allowed": not anonymous_allowed} + } + }, + token_validation={"anonymous_allowed": anonymous_allowed}, + ) + result = await _authorize_request(None, config) + if anonymous_allowed: + assert isinstance(result, ClaimsIdentity) + assert result.allow_anonymous is True + else: + assert isinstance(result, HttpResponse) + assert result.status_code == 401 + + +@pytest.mark.asyncio +async def test_root_configuration_authorizes_signed_token(monkeypatch): + config = Configuration( + connections={"SERVICE_CONNECTION": {"settings": {"client_id": "client"}}} + ) + private_key, public_key = generate_rsa_keypair() + + async def get_signing_key(uri, header): + return public_key + + monkeypatch.setattr( + _authorize_request_module.JwtTokenValidator._jwk_client_manager, + "get_signing_key", + get_signing_key, + ) + token = make_signed_jwt(private_key, {"aud": "client"}) + result = await _authorize_request(f"Bearer {token}", config) + assert isinstance(result, ClaimsIdentity) + assert result.claims["aud"] == "client" + assert result.allow_anonymous is False + + +def test_validator_cache_reuses_same_owner_and_isolates_different_owners(): + class Owner: + pass + + first_owner, second_owner = Owner(), Owner() + config = Configuration(token_validation={"anonymous_allowed": True}) + resolve = _authorize_request_module._resolve_token_validator + first = resolve(config, first_owner) + assert resolve(config, first_owner) is first + assert resolve(config, second_owner) is not first + assert resolve(config) is not resolve(config) + + +def test_validator_cache_does_not_retain_owner(): + class Owner: + pass + + owner = Owner() + owner_ref = ref(owner) + validator = _authorize_request_module._resolve_token_validator( + Configuration(), owner + ) + cache = _authorize_request_module._TOKEN_VALIDATION_CACHE + assert cache[owner] is validator + del owner + assert owner_ref() is None + assert validator not in cache.values() + + @pytest.mark.asyncio async def test_authorize_request_returns_401_when_token_validation_fails(): validator = MagicMock() diff --git a/tests/hosting_core/authorization/test_jwt_connection_settings.py b/tests/hosting_core/authorization/test_jwt_connection_settings.py new file mode 100644 index 000000000..19fe984b3 --- /dev/null +++ b/tests/hosting_core/authorization/test_jwt_connection_settings.py @@ -0,0 +1,106 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity.config import Configuration, _resolve +from microsoft_agents.hosting.core.authorization.connection_settings_base import ( + ConnectionSettingsBase, +) +from microsoft_agents.hosting.core.authorization.jwt import JwtConnectionSettings + + +@pytest.mark.parametrize( + "tenant_id, authority, effective_tenant, cloud", + [ + (None, None, "common", "com"), + ("tenant", None, "tenant", "com"), + ("tenant", "https://login.microsoftonline.us", "tenant", "us"), + ("tenant", "https://login.microsoftonline.com/common", "common", "com"), + ("common", "https://login.microsoftonline.com/concrete", "concrete", "com"), + ], +) +def test_default_issuers_use_effective_tenant_and_cloud( + tenant_id, authority, effective_tenant, cloud +): + settings = JwtConnectionSettings.from_mapping( + tenant_id=tenant_id, authority=authority + ) + assert settings.issuers == [ + f"https://api.botframework.{cloud}", + f"https://sts.windows.net/{effective_tenant}/", + f"https://login.microsoftonline.{cloud}/{effective_tenant}/v2.0", + ] + + +@pytest.mark.parametrize( + "value", + [ + ["https://first.example.com/", "https://second.example.com/"], + "https://first.example.com/ https://second.example.com/", + "https://first.example.com/, https://second.example.com/", + ], +) +def test_explicit_issuers_override_defaults(value): + settings = JwtConnectionSettings.from_mapping( + {"ISSUERS": value, "VALIDATE_ISSUER": "true", "ANONYMOUS_ALLOWED": "false"} + ) + assert settings.issuers == [ + "https://first.example.com/", + "https://second.example.com/", + ] + assert settings.validate_issuer is True + assert settings.anonymous_allowed is False + + +def test_indexed_issuers_from_environment(): + config = Configuration( + _resolve( + { + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__ISSUERS__10": "https://third.example.com/", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__ISSUERS__2": "https://second.example.com/", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__ISSUERS__0": "https://first.example.com/", + } + ) + ) + connection = config.get_section("CONNECTIONS::SERVICE_CONNECTION") + assert connection["SETTINGS"]["ISSUERS"] == [ + "https://first.example.com/", + "https://second.example.com/", + "https://third.example.com/", + ] + assert JwtConnectionSettings.from_configuration(connection).issuers == [ + "https://first.example.com/", + "https://second.example.com/", + "https://third.example.com/", + ] + + +def test_unresolved_indexed_issuers_require_normalization(): + with pytest.raises(ValueError, match="ISSUERS"): + JwtConnectionSettings.from_mapping( + {"ISSUERS": {"0": "https://first.example.com/"}} + ) + + +def test_base_settings_bind_aliases_scopes_and_provider_extras(): + settings = ConnectionSettingsBase.from_mapping( + { + "SETTINGS": { + "CLIENTID": "client", + "TENANT_ID": "tenant", + "AUTHORITYENDPOINT": "https://login.microsoftonline.us", + "ALT_BLUEPRINT_ID": "blueprint", + "SCOPES": "scope1 scope2", + "provider_option": "value", + "extras": {"fallback_option": "fallback"}, + } + } + ) + assert settings.client_id == "client" + assert settings.tenant_id == "tenant" + assert settings.authority == "https://login.microsoftonline.us" + assert settings.alternate_blueprint_connection_name == "blueprint" + assert settings.scopes == ["scope1", "scope2"] + assert settings.extras["provider_option"] == "value" + assert settings.extras["fallback_option"] == "fallback" diff --git a/tests/hosting_core/authorization/test_token_validation_configuration.py b/tests/hosting_core/authorization/test_token_validation_configuration.py new file mode 100644 index 000000000..17b5e3afa --- /dev/null +++ b/tests/hosting_core/authorization/test_token_validation_configuration.py @@ -0,0 +1,270 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity.config import Configuration, _resolve +from microsoft_agents.hosting.core import AgentAuthConfiguration +from microsoft_agents.hosting.core.authorization.jwt import ( + JwtTokenValidator, + TokenValidationConfiguration, +) + +from tests._common.jwt_test_utils import generate_rsa_keypair, make_signed_jwt + + +def _root(**token_validation): + return Configuration( + { + "connections": { + "SERVICE_CONNECTION": { + "settings": { + "client_id": "service-client", + "tenant_id": "service-tenant", + "anonymous_allowed": True, + } + }, + "MCS": { + "settings": {"client_id": "mcs-client", "tenant_id": "mcs-tenant"} + }, + }, + "token_validation": token_validation, + } + ) + + +def test_absent_policy_uses_service_connection_and_all_connection_audiences(): + root = _root() + del root["token_validation"] + policy = TokenValidationConfiguration.from_configuration(root) + assert policy.tenant_id == "service-tenant" + assert policy.audiences == ["service-client", "mcs-client"] + assert policy.anonymous_allowed is True + assert policy.default_jwt_settings is policy.connections["SERVICE_CONNECTION"] + assert policy.get_connection_settings("MCS-CLIENT") is policy.connections["MCS"] + assert policy.get_connection_settings("unmatched") is policy.default_jwt_settings + + +@pytest.mark.parametrize("values", [["explicit-client"], {"0": "explicit-client"}]) +def test_explicit_policy_overrides_connection_defaults(values): + policy = TokenValidationConfiguration.from_configuration( + _root(audiences=values, tenant_id="explicit-tenant", anonymous_allowed=False) + ) + assert policy.audiences == ["explicit-client"] + assert policy.validate_audience("EXPLICIT-CLIENT") + assert not policy.validate_audience("service-client") + assert policy.tenant_id == "explicit-tenant" + assert policy.anonymous_allowed is False + + +@pytest.mark.parametrize("audience", [None, "", [], ["mcs-client"], 123, {}]) +def test_malformed_audiences_are_not_matched_or_routed(audience): + policy = TokenValidationConfiguration.from_configuration(_root()) + assert policy.validate_audience(audience) is False + assert policy.get_connection_settings(audience) is None + + +def test_legacy_configuration_preserves_explicit_issuer_policy(): + legacy = AgentAuthConfiguration( + client_id="legacy-client", + tenant_id="legacy-tenant", + anonymous_allowed=True, + validate_issuer=True, + issuers=["https://custom.example.com/"], + ) + policy = JwtTokenValidator(legacy).config + assert policy.audiences == ["legacy-client"] + assert policy.tenant_id == "legacy-tenant" + assert policy.anonymous_allowed is True + assert policy.default_jwt_settings.validate_issuer is True + assert policy.default_jwt_settings.issuers == ["https://custom.example.com/"] + + +@pytest.mark.asyncio +async def test_configuration_based_validator_checks_signature_and_explicit_audience( + monkeypatch, +): + private_key, public_key = generate_rsa_keypair() + validator = JwtTokenValidator(_root(audiences=["explicit-client"])) + uris = [] + + async def get_signing_key(uri, header): + uris.append(uri) + return public_key + + monkeypatch.setattr( + validator._jwk_client_manager, "get_signing_key", get_signing_key + ) + token = make_signed_jwt(private_key, {"aud": "explicit-client"}) + assert (await validator.validate_token(token)).claims["aud"] == "explicit-client" + assert uris == [ + "https://login.microsoftonline.com/service-tenant/discovery/v2.0/keys" + ] + with pytest.raises(ValueError, match="Invalid audience"): + await validator.validate_token( + make_signed_jwt(private_key, {"aud": "service-client"}) + ) + + +@pytest.mark.asyncio +async def test_empty_configuration_rejects_tokens_without_jwks_lookup(monkeypatch): + private_key, _ = generate_rsa_keypair() + validator = JwtTokenValidator(Configuration()) + + async def unexpected_lookup(uri, header): + pytest.fail("A configuration without routing settings must not fetch keys") + + monkeypatch.setattr( + validator._jwk_client_manager, "get_signing_key", unexpected_lookup + ) + with pytest.raises(ValueError, match="Invalid audience"): + await validator.validate_token(make_signed_jwt(private_key, {"aud": "client"})) + + +def test_normalized_environment_policy_overrides_service_defaults(): + config = Configuration( + _resolve( + { + "TOKENVALIDATION__AUDIENCES__10": "client-10", + "TOKENVALIDATION__AUDIENCES__2": "client-2", + "TOKENVALIDATION__TENANTID": "explicit-tenant", + "TOKENVALIDATION__ANONYMOUSALLOWED": "false", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID": "service-client", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__ANONYMOUSALLOWED": "true", + } + ) + ) + policy = TokenValidationConfiguration.from_configuration(config) + assert policy.audiences == ["client-2", "client-10"] + assert policy.tenant_id == "explicit-tenant" + assert policy.anonymous_allowed is False + assert policy.default_jwt_settings.anonymous_allowed is True + + +def test_no_service_connection_has_no_default_route_or_inherited_anonymous_access(): + config = _root() + del config["connections"]["SERVICE_CONNECTION"] + config["connections"]["MCS"]["settings"]["anonymous_allowed"] = True + policy = TokenValidationConfiguration.from_configuration(config) + assert policy.audiences == ["mcs-client"] + assert policy.tenant_id is None + assert policy.anonymous_allowed is False + assert policy.default_jwt_settings is None + assert policy.get_connection_settings("mcs-client") is policy.connections["MCS"] + assert policy.get_connection_settings("unknown") is None + + +@pytest.mark.parametrize( + "config, path, error_type", + [ + ({"token_validation": "invalid"}, "token_validation", TypeError), + ({"connections": []}, "connections", TypeError), + ({"connections": {"MCS": "invalid"}}, "connections::MCS", TypeError), + ( + {"connections": {"MCS": {"settings": []}}}, + "connections::MCS::settings", + TypeError, + ), + ( + {"token_validation": {"anonymous_allowed": "invalid"}}, + "token_validation::anonymous_allowed", + ValueError, + ), + ( + {"connections": {"MCS": {"settings": {"validate_issuer": "invalid"}}}}, + "connections::MCS::settings::validate_issuer", + ValueError, + ), + ( + {"connections": {"MCS": {"settings": {"anonymous_allowed": "invalid"}}}}, + "connections::MCS::settings::anonymous_allowed", + ValueError, + ), + ], +) +def test_invalid_configuration_reports_full_path(config, path, error_type): + with pytest.raises(error_type) as error: + TokenValidationConfiguration.from_mapping(config) + assert path in str(error.value) + assert "invalid" not in str(error.value) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("with_connection", [False, True]) +async def test_missing_routing_settings_reject_before_fetching_keys( + monkeypatch, with_connection +): + config = Configuration(token_validation={"audiences": ["explicit-client"]}) + if with_connection: + config["connections"] = {"MCS": {"settings": {"client_id": "mcs-client"}}} + validator = JwtTokenValidator(config) + assert validator.config.validate_audience("explicit-client") + private_key, _ = generate_rsa_keypair() + + async def unexpected_lookup(uri, header): + pytest.fail("An unmatched audience without a default route must not fetch keys") + + monkeypatch.setattr( + validator._jwk_client_manager, "get_signing_key", unexpected_lookup + ) + with pytest.raises(ValueError, match="Invalid audience"): + await validator.validate_token( + make_signed_jwt(private_key, {"aud": "explicit-client"}) + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "cloud, issuer_cloud, validate_issuer, mismatch_tid, accepted", + [ + ("com", "com", True, False, True), + ("us", "us", True, False, True), + ("us", "com", True, False, False), + ("com", "com", True, True, False), + ("com", "com", False, True, False), + ], +) +async def test_configuration_path_routes_and_validates_matched_connection( + monkeypatch, cloud, issuer_cloud, validate_issuer, mismatch_tid, accepted +): + service_tenant = "11111111-1111-1111-1111-111111111111" + matched_tenant = "22222222-2222-2222-2222-222222222222" + config = _root() + config["connections"]["SERVICE_CONNECTION"]["settings"].update( + tenant_id=service_tenant, validate_issuer=True + ) + config["connections"]["MCS"]["settings"].update( + tenant_id=matched_tenant, + authority=f"https://login.microsoftonline.{cloud}", + validate_issuer=validate_issuer, + ) + validator = JwtTokenValidator(config) + private_key, public_key = generate_rsa_keypair() + uris = [] + + async def get_signing_key(uri, header): + uris.append(uri) + return public_key + + monkeypatch.setattr( + validator._jwk_client_manager, "get_signing_key", get_signing_key + ) + token = make_signed_jwt( + private_key, + { + "aud": "MCS-CLIENT", + "iss": f"https://login.microsoftonline.{issuer_cloud}/{matched_tenant}/v2.0", + "tid": service_tenant if mismatch_tid else matched_tenant, + }, + ) + if accepted: + identity = await validator.validate_token(token) + assert identity.claims["aud"] == "MCS-CLIENT" + assert identity.allow_anonymous is False + else: + with pytest.raises(ValueError, match="Invalid issuer"): + await validator.validate_token(token) + expected_tenant = matched_tenant if cloud == "us" else service_tenant + assert uris == [ + f"https://login.microsoftonline.{cloud}/{expected_tenant}/discovery/v2.0/keys" + ] From 8a2fe395234c6aeb6f24e523e6e1679ea3fdff77 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 29 Oct 2026 19:11:54 -0700 Subject: [PATCH 4/9] Formatting --- .../hosting/core/authorization/jwt/_authorize_request.py | 7 +++---- .../hosting/fastapi/jwt_authorization_middleware.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) 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 6eceda18f..3c92c8b29 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 @@ -18,6 +18,7 @@ _TOKEN_VALIDATION_CACHE: WeakKeyDictionary[Any, JwtTokenValidator] = WeakKeyDictionary() + def _resolve_token_validator( auth_config: Configuration | AgentAuthConfiguration | None, cache_key: Any = None, @@ -36,6 +37,7 @@ def _resolve_token_validator( return validator return None + async def _authorize_request( authorization_header: str | None, auth_config: Configuration | AgentAuthConfiguration | None, @@ -50,10 +52,7 @@ async def _authorize_request( :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. """ - validator = _resolve_token_validator( - auth_config, - cache_key - ) + validator = _resolve_token_validator(auth_config, cache_key) if not validator: return HttpResponse( 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 6a68da65a..f0eac8c7d 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 @@ -37,7 +37,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): res = await _authorize_request( request.headers.get("Authorization"), getattr(app.state, "agent_configuration", None), - cache_key=app + cache_key=app, ) if isinstance(res, HttpResponse): From 02cae35ae45eb3ccf14038b315c87341a27d1b4d Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 29 Oct 2026 19:19:04 -0700 Subject: [PATCH 5/9] Small test fix --- .../test_jwt_authorization_middleware.py | 26 +++++++++++++------ .../test_jwt_authorization_middleware.py | 11 +++++--- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/tests/hosting_aiohttp/test_jwt_authorization_middleware.py b/tests/hosting_aiohttp/test_jwt_authorization_middleware.py index e351128fe..0388fcd7c 100644 --- a/tests/hosting_aiohttp/test_jwt_authorization_middleware.py +++ b/tests/hosting_aiohttp/test_jwt_authorization_middleware.py @@ -56,13 +56,16 @@ async def handler(request): "_authorize_request", new=AsyncMock(return_value=claims), ) as authorize: + request = _RequestStub(auth_config, "Bearer token") response = await _jwt_middleware_module._jwt_authorization_middleware( - _RequestStub(auth_config, "Bearer token"), handler + request, handler ) assert response.status == 200 assert _response_json(response) == {"aud": "app-id"} - authorize.assert_awaited_once_with("Bearer token", auth_config) + authorize.assert_awaited_once_with( + "Bearer token", auth_config, cache_key=request.app + ) @pytest.mark.asyncio @@ -80,8 +83,9 @@ async def test_aiohttp_middleware_converts_http_response(): ) ), ) as authorize: + request = _RequestStub(auth_config, "Bearer token") response = await _jwt_middleware_module._jwt_authorization_middleware( - _RequestStub(auth_config, "Bearer token"), handler + request, handler ) assert response.status == 401 @@ -89,7 +93,9 @@ async def test_aiohttp_middleware_converts_http_response(): "error": "Invalid token or authentication failed." } handler.assert_not_awaited() - authorize.assert_awaited_once_with("Bearer token", auth_config) + authorize.assert_awaited_once_with( + "Bearer token", auth_config, cache_key=request.app + ) @pytest.mark.asyncio @@ -106,11 +112,14 @@ async def handler(request): "_authorize_request", new=AsyncMock(return_value=claims), ) as authorize: - response = await handler(_RequestStub(auth_config, "Bearer token")) + request = _RequestStub(auth_config, "Bearer token") + response = await handler(request) assert response.status == 200 assert _response_json(response) == {"aud": "decorator-app"} - authorize.assert_awaited_once_with("Bearer token", auth_config) + authorize.assert_awaited_once_with( + "Bearer token", auth_config, cache_key=request.app + ) @pytest.mark.asyncio @@ -131,8 +140,9 @@ async def handler(request): ) ), ) as authorize: - response = await handler(_RequestStub(auth_config)) + request = _RequestStub(auth_config) + response = await handler(request) assert response.status == 401 assert _response_json(response) == {"error": "Authorization header not found"} - authorize.assert_awaited_once_with(None, auth_config) + authorize.assert_awaited_once_with(None, auth_config, cache_key=request.app) diff --git a/tests/hosting_fastapi/test_jwt_authorization_middleware.py b/tests/hosting_fastapi/test_jwt_authorization_middleware.py index b88981696..7b6da4281 100644 --- a/tests/hosting_fastapi/test_jwt_authorization_middleware.py +++ b/tests/hosting_fastapi/test_jwt_authorization_middleware.py @@ -85,7 +85,9 @@ async def downstream(scope, receive, send): assert downstream_called is True assert _status(messages) == 200 - authorize.assert_awaited_once_with("Bearer token", auth_config) + authorize.assert_awaited_once_with( + "Bearer token", auth_config, cache_key=scope["app"] + ) @pytest.mark.asyncio @@ -94,6 +96,7 @@ async def test_fastapi_middleware_converts_http_response_without_calling_downstr messages = [] downstream = AsyncMock() middleware = JwtAuthorizationMiddleware(downstream) + scope = _scope(auth_config, "Bearer bad") with patch( "microsoft_agents.hosting.fastapi.jwt_authorization_middleware._authorize_request", @@ -105,14 +108,16 @@ async def test_fastapi_middleware_converts_http_response_without_calling_downstr ), ) as authorize: await middleware( - _scope(auth_config, "Bearer bad"), + scope, _receive, lambda message: _record_send(messages, message), ) assert _status(messages) == 401 downstream.assert_not_awaited() - authorize.assert_awaited_once_with("Bearer bad", auth_config) + authorize.assert_awaited_once_with( + "Bearer bad", auth_config, cache_key=scope["app"] + ) @pytest.mark.asyncio From 1d061d2cf0e2b2f1daff11f2ea7019e152e8bd73 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 29 Oct 2026 19:40:56 -0700 Subject: [PATCH 6/9] Addressing PR feedback --- .../activity/config/configuration.py | 2 +- .../jwt/token_validation_configuration.py | 2 +- .../test_token_validation_configuration.py | 67 +++++++++++++++---- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/config/configuration.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/config/configuration.py index ee3b25f9c..ae6fd066f 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/config/configuration.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/config/configuration.py @@ -352,7 +352,7 @@ def get_str_list( name = self._resolve_path(_PATH_SEPARATOR.join(path) or keys[0]) before_split = _handle_str(val, None, name) if before_split is None: - return default + return default if default is not None else [] if "," in before_split: return [s.strip() for s in before_split.split(",")] return [s.strip() for s in before_split.strip().split()] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py index ade7594b3..943460349 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/token_validation_configuration.py @@ -26,7 +26,7 @@ def _from_configuration( token_validation = configuration.get_section( xpath("token_validation"), default=Configuration() ) - audiences = token_validation.collect_str("audiences::*", "AUDIENCES::*") + audiences = token_validation.get_str_list(xpath("audiences")) tenant_id = token_validation.get_strn(xpath("tenant_id")) anonymous_allowed = token_validation.get_booln(xpath("anonymous_allowed")) diff --git a/tests/hosting_core/authorization/test_token_validation_configuration.py b/tests/hosting_core/authorization/test_token_validation_configuration.py index 17b5e3afa..080ae7d22 100644 --- a/tests/hosting_core/authorization/test_token_validation_configuration.py +++ b/tests/hosting_core/authorization/test_token_validation_configuration.py @@ -45,18 +45,43 @@ def test_absent_policy_uses_service_connection_and_all_connection_audiences(): assert policy.get_connection_settings("unmatched") is policy.default_jwt_settings -@pytest.mark.parametrize("values", [["explicit-client"], {"0": "explicit-client"}]) -def test_explicit_policy_overrides_connection_defaults(values): +@pytest.mark.parametrize( + "values, expected", + [ + (["explicit-client"], ["explicit-client"]), + ("explicit-client", ["explicit-client"]), + ("explicit-client other-client", ["explicit-client", "other-client"]), + ("explicit-client, other-client", ["explicit-client", "other-client"]), + ], +) +def test_explicit_policy_overrides_connection_defaults(values, expected): policy = TokenValidationConfiguration.from_configuration( _root(audiences=values, tenant_id="explicit-tenant", anonymous_allowed=False) ) - assert policy.audiences == ["explicit-client"] + assert policy.audiences == expected assert policy.validate_audience("EXPLICIT-CLIENT") assert not policy.validate_audience("service-client") + assert not policy.validate_audience("mcs-client") assert policy.tenant_id == "explicit-tenant" assert policy.anonymous_allowed is False +@pytest.mark.parametrize("value", [None, [], "", " "]) +def test_empty_audiences_use_connection_defaults(value): + policy = TokenValidationConfiguration.from_configuration(_root(audiences=value)) + assert policy.audiences == ["service-client", "mcs-client"] + assert policy.validate_audience("service-client") + assert policy.validate_audience("mcs-client") + + +@pytest.mark.parametrize( + "value", [{"0": "explicit-client"}, {"client": "explicit-client"}] +) +def test_unresolved_audience_mappings_are_rejected(value): + with pytest.raises(ValueError, match="token_validation::audiences"): + TokenValidationConfiguration.from_configuration(_root(audiences=value)) + + @pytest.mark.parametrize("audience", [None, "", [], ["mcs-client"], 123, {}]) def test_malformed_audiences_are_not_matched_or_routed(audience): policy = TokenValidationConfiguration.from_configuration(_root()) @@ -81,11 +106,13 @@ def test_legacy_configuration_preserves_explicit_issuer_policy(): @pytest.mark.asyncio +@pytest.mark.parametrize("audiences", [["explicit-client"], "explicit-client"]) async def test_configuration_based_validator_checks_signature_and_explicit_audience( monkeypatch, + audiences, ): private_key, public_key = generate_rsa_keypair() - validator = JwtTokenValidator(_root(audiences=["explicit-client"])) + validator = JwtTokenValidator(_root(audiences=audiences)) uris = [] async def get_signing_key(uri, header): @@ -100,10 +127,11 @@ async def get_signing_key(uri, header): assert uris == [ "https://login.microsoftonline.com/service-tenant/discovery/v2.0/keys" ] - with pytest.raises(ValueError, match="Invalid audience"): - await validator.validate_token( - make_signed_jwt(private_key, {"aud": "service-client"}) - ) + for rejected_audience in ["service-client", "mcs-client"]: + with pytest.raises(ValueError, match="Invalid audience"): + await validator.validate_token( + make_signed_jwt(private_key, {"aud": rejected_audience}) + ) @pytest.mark.asyncio @@ -121,12 +149,26 @@ async def unexpected_lookup(uri, header): await validator.validate_token(make_signed_jwt(private_key, {"aud": "client"})) -def test_normalized_environment_policy_overrides_service_defaults(): - config = Configuration( - _resolve( +@pytest.mark.parametrize( + "audience_config, expected", + [ + ( { "TOKENVALIDATION__AUDIENCES__10": "client-10", "TOKENVALIDATION__AUDIENCES__2": "client-2", + }, + ["client-2", "client-10"], + ), + ({"TOKENVALIDATION__AUDIENCES": "restricted-client"}, ["restricted-client"]), + ], +) +def test_normalized_environment_policy_overrides_service_defaults( + audience_config, expected +): + config = Configuration( + _resolve( + { + **audience_config, "TOKENVALIDATION__TENANTID": "explicit-tenant", "TOKENVALIDATION__ANONYMOUSALLOWED": "false", "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID": "service-client", @@ -135,7 +177,8 @@ def test_normalized_environment_policy_overrides_service_defaults(): ) ) policy = TokenValidationConfiguration.from_configuration(config) - assert policy.audiences == ["client-2", "client-10"] + assert policy.audiences == expected + assert not policy.validate_audience("service-client") assert policy.tenant_id == "explicit-tenant" assert policy.anonymous_allowed is False assert policy.default_jwt_settings.anonymous_allowed is True From 4956a998aaf78138b6be4d08aa5883cc621af17d Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 29 Oct 2026 19:46:10 -0700 Subject: [PATCH 7/9] Changelog --- changelog.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/changelog.md b/changelog.md index 2fed6ddbf..a394a6340 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,17 @@ +# Microsoft 365 Agents SDK for Python - Release Notes v1.10.0 (Unreleased) + +**Release Date:** Unreleased +**Previous Version:** 1.9.0 (Released 2026-10-19) + +## Major Features & Enhancements + +- **Shared Configuration API**: Added a dictionary-backed `Configuration` API with nested path and alias lookup, typed getters, wildcard collection, configurable settings binding, and mapping/default resolution with indexed-list normalization. Retained the existing `load_configuration_from_env()` return contract (#636). +- **JWT Authorization Configuration**: Added `ConnectionSettingsBase`, `JwtConnectionSettings`, and `TokenValidationConfiguration` to bind inbound JWT policies from application configuration, including explicit audiences, tenant and anonymous-access overrides, and per-connection issuer settings. Retained legacy `AgentAuthConfiguration` support and added application-scoped validator caching without changing `Connections` or `ConnectionManager` (#638). + +- **Explicit JWT Audience Lists**: Scalar and delimited audience strings now bind as explicit allow-lists instead of falling back to all configured connection client IDs. Missing or empty audiences continue to use connection defaults (#638). + +--- + # Microsoft 365 Agents SDK for Python - Release Notes v1.9.0 **Release Date:** 2026-10-19 From 1a6d053f44965b7cc23a489d6ddc2e1e2e1e5a60 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 29 Oct 2026 19:54:27 -0700 Subject: [PATCH 8/9] Addressing PR feedback --- .../fastapi/jwt_authorization_middleware.py | 2 +- .../test_jwt_authorization_middleware.py | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) 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 f0eac8c7d..1041547c6 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 @@ -36,7 +36,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): request = Request(scope, receive=receive) res = await _authorize_request( request.headers.get("Authorization"), - getattr(app.state, "agent_configuration", None), + getattr(getattr(app, "state", None), "agent_configuration", None), cache_key=app, ) diff --git a/tests/hosting_fastapi/test_jwt_authorization_middleware.py b/tests/hosting_fastapi/test_jwt_authorization_middleware.py index 7b6da4281..834b4efe3 100644 --- a/tests/hosting_fastapi/test_jwt_authorization_middleware.py +++ b/tests/hosting_fastapi/test_jwt_authorization_middleware.py @@ -6,7 +6,7 @@ from types import SimpleNamespace import pytest -from fastapi import Depends, Request +from fastapi import Depends, FastAPI, Request from microsoft_agents.hosting.core.authorization import ( AgentAuthConfiguration, @@ -120,6 +120,35 @@ async def test_fastapi_middleware_converts_http_response_without_calling_downstr ) +@pytest.mark.asyncio +@pytest.mark.parametrize("missing", ["app", "state"]) +async def test_fastapi_middleware_returns_configuration_error_without_app_or_state( + missing, +): + scope = _scope(AgentAuthConfiguration()) + if missing == "app": + del scope["app"] + else: + app = FastAPI() + del app.state + scope["app"] = app + messages = [] + downstream = AsyncMock() + + await JwtAuthorizationMiddleware(downstream)( + scope, _receive, lambda message: _record_send(messages, message) + ) + + assert _status(messages) == 500 + body = b"".join( + message.get("body", b"") + for message in messages + if message["type"] == "http.response.body" + ) + assert body == b'{"error":"Agent Authentication configuration not found"}' + downstream.assert_not_awaited() + + @pytest.mark.asyncio async def test_fastapi_decorator_stores_claims_and_calls_handler(): auth_config = AgentAuthConfiguration() From 853854e2a6706bde8928c418edd4d2e2ca3f60fa Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 29 Oct 2026 20:35:21 -0700 Subject: [PATCH 9/9] Back-compat fix --- .../authorization/connection_settings_base.py | 11 +++++- .../test_token_validation_configuration.py | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_settings_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_settings_base.py index 29ec97b36..b98729d0b 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_settings_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_settings_base.py @@ -8,7 +8,12 @@ from typing import Any, TYPE_CHECKING from typing_extensions import Self -from microsoft_agents.activity.config import Configuration, _Configurable, xpath +from microsoft_agents.activity.config import ( + Configuration, + _Configurable, + _resolve, + xpath, +) if TYPE_CHECKING: from .agent_auth_configuration import AgentAuthConfiguration @@ -143,6 +148,10 @@ def _from_agent_auth_config(cls, agent_auth_config: AgentAuthConfiguration) -> S attrs = dict(vars(agent_auth_config)) attrs.update(attrs.pop("provider_settings", {})) attrs["ISSUERS"] = agent_auth_config.ISSUERS + if isinstance(attrs.get("SCOPES"), Mapping): + # TODO: Temporary compatibility for legacy indexed scopes; the next + # PR's configuration-loader migration will normalize these mappings. + attrs["SCOPES"] = _resolve({"SCOPES": attrs["SCOPES"]})["SCOPES"] return cls.from_mapping(attrs) @classmethod diff --git a/tests/hosting_core/authorization/test_token_validation_configuration.py b/tests/hosting_core/authorization/test_token_validation_configuration.py index 080ae7d22..657a30099 100644 --- a/tests/hosting_core/authorization/test_token_validation_configuration.py +++ b/tests/hosting_core/authorization/test_token_validation_configuration.py @@ -3,6 +3,7 @@ import pytest +from microsoft_agents.activity import load_configuration_from_env from microsoft_agents.activity.config import Configuration, _resolve from microsoft_agents.hosting.core import AgentAuthConfiguration from microsoft_agents.hosting.core.authorization.jwt import ( @@ -105,6 +106,43 @@ def test_legacy_configuration_preserves_explicit_issuer_policy(): assert policy.default_jwt_settings.issuers == ["https://custom.example.com/"] +@pytest.mark.parametrize( + "indexed_scopes, expected", + [ + ({"0": "resource/.default"}, ["resource/.default"]), + ( + {"10": "scope-10", "2": "scope-2", "0": "scope-0"}, + ["scope-0", "scope-2", "scope-10"], + ), + ], +) +def test_legacy_environment_scopes_bind_without_mutating_configuration( + indexed_scopes, expected +): + env = { + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID": "legacy-client", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CONNECTIONNAME": ( + "SERVICE_CONNECTION" + ), + **{ + f"CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES__{index}": scope + for index, scope in indexed_scopes.items() + }, + } + loaded = load_configuration_from_env(env) + raw_scopes = loaded["CONNECTIONS"]["SERVICE_CONNECTION"]["SETTINGS"]["SCOPES"] + legacy = AgentAuthConfiguration( + **loaded["CONNECTIONS"]["SERVICE_CONNECTION"]["SETTINGS"] + ) + + policy = JwtTokenValidator(legacy).config + + assert policy.default_jwt_settings.scopes == expected + assert policy.connections["SERVICE_CONNECTION"].scopes == expected + assert legacy.SCOPES is raw_scopes + assert raw_scopes == indexed_scopes + + @pytest.mark.asyncio @pytest.mark.parametrize("audiences", [["explicit-client"], "explicit-client"]) async def test_configuration_based_validator_checks_signature_and_explicit_audience(