From 6aa0cafa2b36c6dedb0aac66bbdfac7993ef1abe Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Tue, 27 Oct 2026 15:43:03 -0700 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 f11b0b65a0504d2d4401d33bb37a75cf62a82248 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 29 Oct 2026 20:26:59 -0700 Subject: [PATCH 09/15] Connection configuraiton overhault --- .../entra_auth_sidecar/__init__.py | 2 +- .../entra_auth_sidecar/_models.py | 159 +-------- .../entra_auth_sidecar/connection_settings.py | 124 +++++++ .../entra_auth_sidecar/constants.py | 8 + .../entra_auth_sidecar/sidecar_auth.py | 22 +- .../entra_auth_sidecar/sidecar_http_client.py | 3 +- .../sidecar_token_credential.py | 4 +- .../readme.md | 31 ++ .../authentication/msal/__init__.py | 2 + .../msal/connection_settings.py | 87 +++++ .../authentication/msal/msal_auth.py | 109 ++++-- .../msal/msal_connection_manager.py | 4 + .../msal/msal_token_credential.py | 3 +- .../readme.md | 30 ++ .../microsoft_agents/hosting/core/__init__.py | 2 + .../hosting/core/authorization/__init__.py | 2 + .../hosting/core/authorization/_log_config.py | 89 ++++- .../access_token_provider_base.py | 15 +- .../authorization/agent_auth_configuration.py | 333 +---------------- .../authorization/anonymous_token_provider.py | 6 +- .../core/authorization/connection_manager.py | 222 +----------- .../authorization/connection_settings_base.py | 196 +--------- .../authorization/connections/__init__.py | 14 + .../connections/agent_auth_configuration.py | 335 ++++++++++++++++++ .../connections/connection_manager.py | 284 +++++++++++++++ .../connections/connection_settings_base.py | 197 ++++++++++ .../{ => connections}/connections.py | 4 +- .../connections/connections_map.py | 43 +++ .../jwt/jwt_connection_settings.py | 2 +- .../rest_channel_service_client_factory.py | 3 +- .../microsoft-agents-hosting-core/readme.md | 18 + .../test_settings.py | 52 ++- .../test_sidecar_auth.py | 31 ++ .../test_connection_settings.py | 119 +++++++ .../test_msal_connection_manager.py | 2 +- .../authorization/test_connections_map.py | 43 +++ tests/hosting_core/test_connection_manager.py | 108 +++++- tests/hosting_core/test_log_config.py | 22 ++ ...est_rest_channel_service_client_factory.py | 21 +- 39 files changed, 1782 insertions(+), 969 deletions(-) create mode 100644 libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py create mode 100644 libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/constants.py create mode 100644 libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/connection_settings.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/__init__.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/agent_auth_configuration.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_manager.py create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_settings_base.py rename libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/{ => connections}/connections.py (95%) create mode 100644 libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connections_map.py create mode 100644 tests/authentication_msal/test_connection_settings.py create mode 100644 tests/hosting_core/authorization/test_connections_map.py diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/__init__.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/__init__.py index d95673a77..6ab4a05c1 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/__init__.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/__init__.py @@ -4,10 +4,10 @@ from .sidecar_auth import SidecarAuth from .sidecar_http_client import SidecarHttpClient from ._models import ( - SidecarConnectionSettings, SidecarRequestOptions, SidecarTokenResult, ) +from .connection_settings import SidecarConnectionSettings from .errors import ( SidecarAuthError, SidecarUnavailableError, diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/_models.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/_models.py index 5a5b029a1..8c1c0d606 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/_models.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/_models.py @@ -3,149 +3,26 @@ from __future__ import annotations -from collections.abc import Mapping - -from microsoft_agents.activity.config._coercion import ( - coerce_bool, - coerce_int, - coerce_float, -) -from microsoft_agents.hosting.core.authorization.connection_settings_base import ( - _ConnectionSettingsBase, +from .connection_settings import SidecarConnectionSettings +from .constants import ( + DEFAULT_SERVICE_NAME, + DEFAULT_BLUEPRINT_SERVICE_NAME, + DEFAULT_SIDECAR_BASE_URL, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + DEFAULT_RETRY_COUNT, ) -DEFAULT_SERVICE_NAME = "default" -DEFAULT_BLUEPRINT_SERVICE_NAME = "agenticblueprint" -DEFAULT_SIDECAR_BASE_URL = "http://localhost:5178" -DEFAULT_REQUEST_TIMEOUT_SECONDS = 30.0 -DEFAULT_RETRY_COUNT = 3 - - -class SidecarConnectionSettings(_ConnectionSettingsBase): - """ - Connection settings for the sidecar-based token provider. - - Extends - :class:`microsoft_agents.hosting.core.authorization.connection_settings_base._ConnectionSettingsBase` - with the sidecar-specific fields, mirroring the .NET - ``Microsoft.Agents.Authentication.EntraAuthSidecar.SidecarConnectionSettings`` - which derives from ``_ConnectionSettingsBase``. Bound from an - :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` (or its raw - ``SETTINGS`` keyword arguments). No secrets/certificates are required: the - sidecar owns all credential management. - """ - - def __init__( - self, - *, - service_name: str | None = None, - blueprint_service_name: str | None = None, - sidecar_base_url: str | None = None, - bypass_local_network_restriction: bool = False, - request_timeout: float = DEFAULT_REQUEST_TIMEOUT_SECONDS, - retry_count: int = DEFAULT_RETRY_COUNT, - scopes: list[str] | None = None, - client_id: str | None = None, - tenant_id: str | None = None, - authority: str | None = None, - alternate_blueprint_connection_name: str | None = None, - ): - super().__init__( - client_id=client_id, - authority=authority, - tenant_id=tenant_id, - scopes=self._normalize_scopes(scopes), - alternate_blueprint_connection_name=alternate_blueprint_connection_name, - ) - # An explicitly blank or whitespace-only value (e.g. binding yields "" or - # " ") must never produce an invalid sidecar endpoint path, so strip and - # normalize to the defaults here. - self.service_name = (service_name or "").strip() or DEFAULT_SERVICE_NAME - self.blueprint_service_name = ( - blueprint_service_name or "" - ).strip() or DEFAULT_BLUEPRINT_SERVICE_NAME - self.sidecar_base_url = sidecar_base_url - # Config values can arrive as strings from ``load_configuration_from_env``, - # so coerce explicitly: ``bool("false")`` would otherwise be ``True`` and - # silently disable the SSRF guard, and ``max(0, "5")`` would raise. - self.bypass_local_network_restriction = coerce_bool( - bypass_local_network_restriction, - default=False, - name="BYPASS_LOCAL_NETWORK_RESTRICTION", - ) - self.request_timeout = coerce_float( - request_timeout, DEFAULT_REQUEST_TIMEOUT_SECONDS, "REQUEST_TIMEOUT" - ) - self.retry_count = coerce_int(retry_count, DEFAULT_RETRY_COUNT, "RETRY_COUNT") - - @staticmethod - def _normalize_scopes(scopes) -> list[str] | None: - """Normalize scopes to a list of strings. - - Accepts a list, a string (space/comma-delimited), or a dict (produced when - scopes are supplied via env as ``SCOPES__0=...`` and parsed into - ``{"0": "..."}``). Returns ``None`` when no scopes are present. - """ - if scopes is None: - return None - if isinstance(scopes, str): - parts = [s.strip() for s in scopes.replace(",", " ").split()] - return [s for s in parts if s] or None - if isinstance(scopes, dict): - scopes = scopes.values() - result = [str(s).strip() for s in scopes if s and str(s).strip()] - return result or None - - @classmethod - def from_configuration(cls, configuration) -> "SidecarConnectionSettings": - """ - Build settings from an :class:`AgentAuthConfiguration` (or a raw SETTINGS - mapping). - - The common base fields (client id, authority, tenant, scopes, alternate - blueprint) are bound via - :meth:`_ConnectionSettingsBase.base_kwargs_from_configuration` so the - shared alias handling lives in one place; only the sidecar-specific - fields are read here. Both attribute-style configuration objects and raw - mappings are tolerated. - """ - base_kwargs = cls.base_kwargs_from_configuration(configuration) - - is_mapping = isinstance(configuration, Mapping) - extras = getattr(configuration, "provider_settings", None) or {} - - def _get(*names, default=None): - for name in names: - value = ( - configuration.get(name) - if is_mapping - else getattr(configuration, name, None) - ) - if value is None: - value = extras.get(name) - if value is not None: - return value - return default - - return cls( - service_name=_get("SERVICE_NAME", "service_name"), - blueprint_service_name=_get( - "BLUEPRINT_SERVICE_NAME", "blueprint_service_name" - ), - sidecar_base_url=_get("SIDECAR_BASE_URL", "sidecar_base_url"), - bypass_local_network_restriction=_get( - "BYPASS_LOCAL_NETWORK_RESTRICTION", - "bypass_local_network_restriction", - default=False, - ), - request_timeout=_get( - "REQUEST_TIMEOUT", - "request_timeout", - default=DEFAULT_REQUEST_TIMEOUT_SECONDS, - ), - retry_count=_get("RETRY_COUNT", "retry_count", default=DEFAULT_RETRY_COUNT), - **base_kwargs, - ) +__all__ = [ + "SidecarConnectionSettings", + "SidecarRequestOptions", + "SidecarTokenResult", + "SidecarProblemDetails", + "DEFAULT_SERVICE_NAME", + "DEFAULT_BLUEPRINT_SERVICE_NAME", + "DEFAULT_SIDECAR_BASE_URL", + "DEFAULT_REQUEST_TIMEOUT_SECONDS", + "DEFAULT_RETRY_COUNT", +] class SidecarRequestOptions: diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py new file mode 100644 index 000000000..4d7b2b323 --- /dev/null +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py @@ -0,0 +1,124 @@ +# 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 +from typing_extensions import Self + +from microsoft_agents.activity.config import Configuration, xpath +from microsoft_agents.activity.config._coercion import ( + coerce_bool, + coerce_int, + coerce_float, +) +from microsoft_agents.hosting.core.authorization.connections import ( + AgentAuthConfiguration, + ConnectionSettingsBase, +) + +from .constants import ( + DEFAULT_SERVICE_NAME, + DEFAULT_BLUEPRINT_SERVICE_NAME, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + DEFAULT_RETRY_COUNT, +) + + +@dataclass(frozen=True) +class SidecarConnectionSettings(ConnectionSettingsBase): + """Typed connection settings for the credential-free sidecar provider.""" + + service_name: str = DEFAULT_SERVICE_NAME + blueprint_service_name: str = DEFAULT_BLUEPRINT_SERVICE_NAME + sidecar_base_url: str | None = None + bypass_local_network_restriction: bool = False + request_timeout: float = DEFAULT_REQUEST_TIMEOUT_SECONDS + retry_count: int = DEFAULT_RETRY_COUNT + + def __post_init__(self): + object.__setattr__( + self, + "service_name", + (self.service_name or "").strip() or DEFAULT_SERVICE_NAME, + ) + object.__setattr__( + self, + "blueprint_service_name", + (self.blueprint_service_name or "").strip() + or DEFAULT_BLUEPRINT_SERVICE_NAME, + ) + object.__setattr__(self, "scopes", self._normalize_scopes(self.scopes) or []) + object.__setattr__( + self, + "bypass_local_network_restriction", + coerce_bool( + self.bypass_local_network_restriction, + default=False, + name="BYPASS_LOCAL_NETWORK_RESTRICTION", + ), + ) + object.__setattr__( + self, + "request_timeout", + coerce_float( + self.request_timeout, DEFAULT_REQUEST_TIMEOUT_SECONDS, "REQUEST_TIMEOUT" + ), + ) + object.__setattr__( + self, + "retry_count", + coerce_int(self.retry_count, DEFAULT_RETRY_COUNT, "RETRY_COUNT"), + ) + + @staticmethod + def _normalize_scopes(scopes) -> list[str] | None: + if scopes is None: + return None + if isinstance(scopes, str): + scopes = scopes.replace(",", " ").split() + if isinstance(scopes, Mapping): + scopes = scopes.values() + return [ + str(scope).strip() for scope in scopes if scope and str(scope).strip() + ] or None + + @classmethod + def from_configuration( + cls, config: Configuration | Mapping | AgentAuthConfiguration | Self + ) -> Self: + if isinstance(config, AgentAuthConfiguration): + return cls._from_agent_auth_config(config) + if isinstance(config, Mapping): + config = Configuration.from_mapping(config) + return super().from_configuration(config) + + @classmethod + def _get_init_kwargs(cls, config: Configuration) -> dict: + normalized = Configuration(config, _base_path=config._base_path) + for alias in xpath("scopes"): + normalized.pop(alias, None) + normalized["scopes"] = cls._normalize_scopes(config.get(xpath("scopes"))) or [] + base = super()._get_init_kwargs(normalized) + base.update( + { + "service_name": config.get_strn(xpath("service_name")), + "blueprint_service_name": config.get_strn( + xpath("blueprint_service_name") + ), + "sidecar_base_url": config.get_strn(xpath("sidecar_base_url")), + "bypass_local_network_restriction": coerce_bool( + config.get(xpath("bypass_local_network_restriction")), + default=False, + name="BYPASS_LOCAL_NETWORK_RESTRICTION", + ), + "request_timeout": config.get_float( + xpath("request_timeout"), default=DEFAULT_REQUEST_TIMEOUT_SECONDS + ), + "retry_count": config.get_int( + xpath("retry_count"), default=DEFAULT_RETRY_COUNT + ), + } + ) + return base diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/constants.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/constants.py new file mode 100644 index 000000000..ea35ebcdc --- /dev/null +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/constants.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +DEFAULT_SERVICE_NAME = "default" +DEFAULT_BLUEPRINT_SERVICE_NAME = "agenticblueprint" +DEFAULT_SIDECAR_BASE_URL = "http://localhost:5178" +DEFAULT_REQUEST_TIMEOUT_SECONDS = 30.0 +DEFAULT_RETRY_COUNT = 3 diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py index 827381e83..96e3a4178 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py @@ -6,6 +6,7 @@ import logging import uuid from datetime import datetime, timedelta, timezone +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core import ( AccessTokenProviderBase, @@ -14,7 +15,8 @@ from azure.core.credentials_async import AsyncTokenCredential -from ._models import SidecarConnectionSettings, SidecarRequestOptions +from ._models import SidecarRequestOptions +from .connection_settings import SidecarConnectionSettings from ._token_expiry import SidecarTokenExpiry from .errors.error_resources import SidecarAuthErrorResources as _Errors from .sidecar_http_client import SidecarHttpClient @@ -52,11 +54,15 @@ class SidecarAuth(AccessTokenProviderBase): def __init__( self, - configuration: AgentAuthConfiguration, + configuration: ( + AgentAuthConfiguration | Configuration | SidecarConnectionSettings + ), *, sidecar_client: SidecarHttpClient | None = None, ): - self._configuration = configuration + self._configuration = ( + configuration if isinstance(configuration, AgentAuthConfiguration) else None + ) self._settings = SidecarConnectionSettings.from_configuration(configuration) if sidecar_client is not None: @@ -90,8 +96,16 @@ def configuration(self) -> AgentAuthConfiguration: such as the alternate-blueprint connection name without depending on a specific provider implementation. """ + if self._configuration is None: + raise ValueError( + "No AgentAuthConfiguration available. Use 'config' instead." + ) return self._configuration + @property + def config(self) -> SidecarConnectionSettings: + return self._settings + @property def _service_name(self) -> str: return self._settings.service_name @@ -121,7 +135,7 @@ def get_token_credential(self) -> AsyncTokenCredential: """ from .sidecar_token_credential import SidecarTokenCredential - return SidecarTokenCredential(self._configuration, provider=self) + return SidecarTokenCredential(self._settings, provider=self) async def acquire_token_on_behalf_of( self, scopes: list[str], user_assertion: str diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_http_client.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_http_client.py index 58c472e05..b019fa873 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_http_client.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_http_client.py @@ -13,12 +13,11 @@ import httpx from ._models import ( - DEFAULT_RETRY_COUNT, - DEFAULT_SIDECAR_BASE_URL, SidecarProblemDetails, SidecarRequestOptions, SidecarTokenResult, ) +from .constants import DEFAULT_RETRY_COUNT, DEFAULT_SIDECAR_BASE_URL from .errors import ( SidecarAuthError, SidecarConfigurationError, diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_token_credential.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_token_credential.py index 6af0faaee..9405dab1f 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_token_credential.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_token_credential.py @@ -9,6 +9,8 @@ from azure.core.credentials_async import AsyncTokenCredential from microsoft_agents.hosting.core import AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration +from .connection_settings import SidecarConnectionSettings from .sidecar_auth import SidecarAuth from ._token_expiry import SidecarTokenExpiry @@ -31,7 +33,7 @@ class SidecarTokenCredential(AsyncTokenCredential): def __init__( self, - config: AgentAuthConfiguration, + config: AgentAuthConfiguration | Configuration | SidecarConnectionSettings, *, provider: SidecarAuth | None = None, ): diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/readme.md b/libraries/microsoft-agents-authentication-entra-auth-sidecar/readme.md index 91d649391..bb9005e1c 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/readme.md +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/readme.md @@ -142,6 +142,37 @@ connection_manager = ConnectionManager( ) ``` +## Typed Connection Settings + +`SidecarConnectionSettings` now derives from the shared `ConnectionSettingsBase`. +`SidecarAuth` accepts a `Configuration`, bound `SidecarConnectionSettings`, or a legacy +`AgentAuthConfiguration`. Read bound settings with `provider.config`. + +```python +from microsoft_agents.activity.config import Configuration +from microsoft_agents.hosting.core.authorization.connections import ConnectionManager +from microsoft_agents.authentication.entra_auth_sidecar import SidecarAuth + +connection_manager = ConnectionManager( + SidecarAuth, + config=Configuration( + connections={ + "SERVICE_CONNECTION": { + "settings": { + "client_id": "your-blueprint-id", + "service_name": "default", + "sidecar_base_url": "http://localhost:5178", + } + } + } + ), +) +``` + +Legacy inputs and the previous settings import remain supported. The legacy +`provider.configuration` view is available only when constructed with an +`AgentAuthConfiguration`; new callers should use `provider.config`. + ## Sidecar endpoint mapping The provider uses the sidecar's unauthenticated endpoint, where `{serviceName}` is a diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py index f1414f49c..f6fabc60c 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/__init__.py @@ -4,8 +4,10 @@ from .msal_auth import MsalAuth from .msal_connection_manager import MsalConnectionManager from .msal_token_credential import MsalTokenCredential +from .connection_settings import MsalConnectionSettings __all__ = [ + "MsalConnectionSettings", "MsalAuth", "MsalConnectionManager", "MsalTokenCredential", diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/connection_settings.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/connection_settings.py new file mode 100644 index 000000000..13ca84817 --- /dev/null +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/connection_settings.py @@ -0,0 +1,87 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from dataclasses import dataclass, field + +from microsoft_agents.activity.config import Configuration, xpath +from microsoft_agents.hosting.core import ( + AgentAuthConfiguration, + AuthTypes, + ConnectionSettingsBase, +) + + +@dataclass(frozen=True) +class MsalConnectionSettings(ConnectionSettingsBase): + """MSAL-specific settings, bound independently of inbound JWT policy.""" + + auth_type: str = AuthTypes.client_secret + client_secret: str | None = field(default=None, repr=False) + cert_pfx_file: str | None = None + send_x5c: bool = False + connection_name: str | None = None + federated_client_id: str | None = None + federated_token_file: str | None = None + idpm_resource: str | None = None + azure_region: str | None = None + + def __post_init__(self): + if self.auth_type == AuthTypes.certificate and not self.cert_pfx_file: + raise ValueError( + "CERT_PFX_FILE is required for certificate authentication." + ) + if ( + self.auth_type == AuthTypes.federated_credentials + and not self.federated_client_id + ): + raise ValueError( + "FEDERATED_CLIENT_ID is required for federated_credentials authentication." + ) + if ( + self.auth_type == AuthTypes.workload_identity + and not self.federated_token_file + ): + raise ValueError( + "FEDERATED_TOKEN_FILE is required for workload_identity authentication." + ) + + @classmethod + def _get_init_kwargs(cls, config: Configuration) -> dict: + base = super()._get_init_kwargs(config) + base.update( + { + "auth_type": config.get_str( + xpath("auth_type"), default=AuthTypes.client_secret + ), + "client_secret": config.get_strn(xpath("client_secret")), + "cert_pfx_file": config.get_strn(xpath("cert_pfx_file")), + "send_x5c": config.get_bool(xpath("send_x5c"), default=False), + "connection_name": config.get_strn(xpath("connection_name")), + "federated_client_id": config.get_strn(xpath("federated_client_id")), + "federated_token_file": config.get_strn(xpath("federated_token_file")), + "idpm_resource": config.get_strn(xpath("idpm_resource")), + "azure_region": config.get_strn( + xpath("azure_region"), xpath("regional_authority") + ), + } + ) + return base + + def _to_agent_auth_config(self) -> AgentAuthConfiguration: + fields = dict( + auth_type=self.auth_type, + client_id=self.client_id, + tenant_id=self.tenant_id, + authority=self.authority, + client_secret=self.client_secret, + cert_pfx_file=self.cert_pfx_file, + send_x5c=self.send_x5c, + connection_name=self.connection_name, + federated_client_id=self.federated_client_id, + federated_token_file=self.federated_token_file, + ALT_BLUEPRINT_NAME=self.alternate_blueprint_connection_name, + idpm_resource=self.idpm_resource, + azure_region=self.azure_region, + scopes=self.scopes, + ) + return AgentAuthConfiguration(**{**self.extras, **fields}) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 15f54afa8..842ba45c8 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -24,6 +24,7 @@ from requests import Session from microsoft_agents.activity._utils import _DeferredString +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core import ( AuthTypes, @@ -32,6 +33,7 @@ ) from microsoft_agents.hosting.core.authorization.telemetry import spans from microsoft_agents.authentication.msal.errors import authentication_errors +from .connection_settings import MsalConnectionSettings logger = logging.getLogger(__name__) @@ -47,7 +49,12 @@ async def _async_acquire_token_for_client(msal_auth_client, *args, **kwargs): class MsalAuth(AccessTokenProviderBase): - def __init__(self, msal_configuration: AgentAuthConfiguration): + def __init__( + self, + msal_configuration: ( + AgentAuthConfiguration | Configuration | MsalConnectionSettings + ), + ): """Initializes the MsalAuth class with the given configuration. :param msal_configuration: The MSAL authentication configuration. Assumed to @@ -55,16 +62,23 @@ def __init__(self, msal_configuration: AgentAuthConfiguration): :type msal_configuration: :class:`microsoft_agents.hosting.core.authorization.agent_auth_configuration.AgentAuthConfiguration` """ - self._msal_configuration = msal_configuration + self._deprecated_config = ( + msal_configuration + if isinstance(msal_configuration, AgentAuthConfiguration) + else None + ) + self._msal_configuration = ( + MsalConnectionSettings._from_agent_auth_config(msal_configuration) + if isinstance(msal_configuration, AgentAuthConfiguration) + else MsalConnectionSettings.from_configuration(msal_configuration) + ) self._msal_auth_client_map: dict[ str, ConfidentialClientApplication | ManagedIdentityClient ] = {} # TokenCache is thread-safe and async-safe per MSAL documentation self._token_cache = TokenCache() - logger.debug( - f"Initializing MsalAuth with configuration: {self._msal_configuration}" - ) + logger.debug("Initializing MsalAuth provider.") @property def configuration(self) -> AgentAuthConfiguration: @@ -73,6 +87,13 @@ def configuration(self) -> AgentAuthConfiguration: :return: The MSAL authentication configuration. :rtype: :class:`microsoft_agents.hosting.core.authorization.agent_auth_configuration.AgentAuthConfiguration` """ + if self._deprecated_config is None: + self._deprecated_config = self._msal_configuration._to_agent_auth_config() + return self._deprecated_config + + @property + def config(self) -> MsalConnectionSettings: + """Return the bound MSAL connection settings.""" return self._msal_configuration async def get_access_token( @@ -112,7 +133,7 @@ async def _get_access_token( """ with spans.GetAccessToken( scopes, - self._msal_configuration.AUTH_TYPE, + self._msal_configuration.auth_type, ): logger.debug( f"Requesting access token for resource: {resource_url}, scopes: {scopes}" @@ -209,26 +230,29 @@ async def acquire_token_on_behalf_of( @staticmethod def _resolve_authority( - config: AgentAuthConfiguration, tenant_id: str | None = None + config: MsalConnectionSettings | AgentAuthConfiguration, + tenant_id: str | None = None, ) -> str: tenant_id = MsalAuth._resolve_tenant_id(config, tenant_id) if not tenant_id: return ( - config.AUTHORITY - or f"https://login.microsoftonline.com/{config.TENANT_ID}" + config.authority + or f"https://login.microsoftonline.com/{config.tenant_id}" ) - if config.AUTHORITY: + if config.authority: return re.sub( r"/(?:common|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?=/|$)", f"/{tenant_id}", - config.AUTHORITY, + config.authority, ) return f"https://login.microsoftonline.com/{tenant_id}" @staticmethod - def _resolve_azure_region(config: AgentAuthConfiguration) -> str | None: + def _resolve_azure_region( + config: MsalConnectionSettings | AgentAuthConfiguration, + ) -> str | None: """Resolves the Azure regional token service (ESTS-R) to use, if configured. :param config: The agent authentication configuration. @@ -239,19 +263,29 @@ def _resolve_azure_region(config: AgentAuthConfiguration) -> str | None: Returns the configured region only when it is populated and non-whitespace, otherwise None so that MSAL falls back to the global token service. """ - azure_region = getattr(config, "AZURE_REGION", None) + azure_region = ( + config.AZURE_REGION + if isinstance(config, AgentAuthConfiguration) + else config.azure_region + ) if azure_region and azure_region.strip(): return azure_region return None @staticmethod - def _resolve_idpm_resource(config: AgentAuthConfiguration) -> str: + def _resolve_idpm_resource( + config: MsalConnectionSettings | AgentAuthConfiguration, + ) -> str: """Resolves the resource URL for Identity Proxy Manager (IDPM) token acquisition. When no resource is configured, defaults to the AzureAdTokenExchange resource. Otherwise the configured value must be a valid absolute URI. """ - idpm_resource = getattr(config, "IDPM_RESOURCE", None) + idpm_resource = ( + config.IDPM_RESOURCE + if isinstance(config, AgentAuthConfiguration) + else config.idpm_resource + ) if not idpm_resource: return "api://AzureAdTokenExchange/.default" @@ -263,35 +297,36 @@ def _resolve_idpm_resource(config: AgentAuthConfiguration) -> str: @staticmethod def _resolve_tenant_id( - config: AgentAuthConfiguration, tenant_id: str | None = None + config: MsalConnectionSettings | AgentAuthConfiguration, + tenant_id: str | None = None, ) -> str: - if not config.TENANT_ID: + if not config.tenant_id: if tenant_id: return tenant_id raise ValueError("TENANT_ID is not set in the configuration.") - if tenant_id or config.TENANT_ID.lower() == "common": + if tenant_id or config.tenant_id.lower() == "common": return tenant_id - return config.TENANT_ID + return config.tenant_id def _create_client_application( self, tenant_id: str | None = None ) -> ConfidentialClientApplication | ManagedIdentityClient: - if self._msal_configuration.AUTH_TYPE in ( + if self._msal_configuration.auth_type in ( AuthTypes.user_managed_identity, AuthTypes.identity_proxy_manager, ): return ManagedIdentityClient( UserAssignedManagedIdentity( - client_id=self._msal_configuration.CLIENT_ID + client_id=self._msal_configuration.client_id ), http_client=Session(), ) - elif self._msal_configuration.AUTH_TYPE == AuthTypes.system_managed_identity: + elif self._msal_configuration.auth_type == AuthTypes.system_managed_identity: return ManagedIdentityClient( SystemAssignedManagedIdentity(), http_client=Session(), @@ -300,18 +335,18 @@ def _create_client_application( authority = MsalAuth._resolve_authority(self._msal_configuration, tenant_id) client_credential = None - if self._msal_configuration.AUTH_TYPE == AuthTypes.client_secret: - client_credential = self._msal_configuration.CLIENT_SECRET - elif self._msal_configuration.AUTH_TYPE == AuthTypes.certificate: + if self._msal_configuration.auth_type == AuthTypes.client_secret: + client_credential = self._msal_configuration.client_secret + elif self._msal_configuration.auth_type == AuthTypes.certificate: client_credential = { - "private_key_pfx_path": self._msal_configuration.CERT_PFX_FILE, + "private_key_pfx_path": self._msal_configuration.cert_pfx_file, } - if self._msal_configuration.SEND_X5C: + if self._msal_configuration.send_x5c: client_credential["public_certificate"] = True - elif self._msal_configuration.AUTH_TYPE == AuthTypes.federated_credentials: + elif self._msal_configuration.auth_type == AuthTypes.federated_credentials: mi_client = ManagedIdentityClient( UserAssignedManagedIdentity( - client_id=self._msal_configuration.FEDERATED_CLIENT_ID + client_id=self._msal_configuration.federated_client_id ), http_client=Session(), ) @@ -332,13 +367,13 @@ def get_assertion() -> str: return result["access_token"] client_credential = {"client_assertion": get_assertion} - elif self._msal_configuration.AUTH_TYPE == AuthTypes.workload_identity: - if not self._msal_configuration.FEDERATED_TOKEN_FILE: + elif self._msal_configuration.auth_type == AuthTypes.workload_identity: + if not self._msal_configuration.federated_token_file: raise ValueError( "FEDERATED_TOKEN_FILE must be set in configuration." ) - federated_token_file = self._msal_configuration.FEDERATED_TOKEN_FILE + federated_token_file = self._msal_configuration.federated_token_file def get_assertion() -> str: with open(federated_token_file, encoding="utf-8") as f: @@ -347,14 +382,14 @@ def get_assertion() -> str: client_credential = {"client_assertion": get_assertion} else: logger.error( - f"Unsupported authentication type: {self._msal_configuration.AUTH_TYPE}" + f"Unsupported authentication type: {self._msal_configuration.auth_type}" ) raise NotImplementedError( str(authentication_errors.AuthenticationTypeNotSupported) ) return ConfidentialClientApplication( - client_id=self._msal_configuration.CLIENT_ID, + client_id=self._msal_configuration.client_id, authority=authority, client_credential=client_credential, azure_region=MsalAuth._resolve_azure_region(self._msal_configuration), @@ -365,7 +400,7 @@ def _client_rep( ) -> str: # Create a unique representation for the client based on tenant_id and instance_id # instance_id None is for when no agentic instance is associated with the request. - tenant_id = tenant_id or self._msal_configuration.TENANT_ID + tenant_id = tenant_id or self._msal_configuration.tenant_id return f"tenant:{tenant_id}.instance:{instance_id}" def _get_client( @@ -393,7 +428,7 @@ def _resolve_scopes_list(self, instance_url: URI, scopes=None) -> list[str]: return scopes temp_list: list[str] = [] - lst = self._msal_configuration.SCOPES or [] + lst = self._msal_configuration.scopes or [] for scope in lst: scope_placeholder = scope if "{instance}" in scope_placeholder.lower(): @@ -443,7 +478,7 @@ async def get_agentic_application_token( return None if ( - self._msal_configuration.AUTH_TYPE == AuthTypes.identity_proxy_manager + self._msal_configuration.auth_type == AuthTypes.identity_proxy_manager and isinstance(msal_auth_client, ManagedIdentityClient) ): resource = MsalAuth._resolve_idpm_resource(self._msal_configuration) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py index 77f983e38..00a970bdb 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_connection_manager.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core import ( AgentAuthConfiguration, ConnectionManager, @@ -23,6 +24,8 @@ def __init__( self, connections_configurations: dict[str, AgentAuthConfiguration] | None = None, connections_map: list[dict[str, str]] | None = None, + *, + config: Configuration | None = None, **kwargs, ): """ @@ -39,5 +42,6 @@ def __init__( provider_factory=MsalAuth, connections_configurations=connections_configurations, connections_map=connections_map, + config=config, **kwargs, ) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py index 7cd4b5866..977f304b6 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_token_credential.py @@ -9,6 +9,7 @@ from microsoft_agents.hosting.core import AgentAuthConfiguration from .msal_auth import MsalAuth +from .connection_settings import MsalConnectionSettings logger = logging.getLogger(__name__) @@ -28,7 +29,7 @@ class MsalTokenCredential(AsyncTokenCredential): def __init__( self, - config: AgentAuthConfiguration, + config: AgentAuthConfiguration | MsalConnectionSettings, *, provider: MsalAuth | None = None, ): diff --git a/libraries/microsoft-agents-authentication-msal/readme.md b/libraries/microsoft-agents-authentication-msal/readme.md index 0cc7e91ce..8f16ab2d3 100644 --- a/libraries/microsoft-agents-authentication-msal/readme.md +++ b/libraries/microsoft-agents-authentication-msal/readme.md @@ -229,6 +229,36 @@ def start_server( [...] ``` +## Typed Connection Settings + +`MsalAuth` accepts `Configuration` or `MsalConnectionSettings` as well as the existing +`AgentAuthConfiguration` input. Use `provider.config` to read bound MSAL settings. + +```python +from microsoft_agents.activity.config import Configuration +from microsoft_agents.authentication.msal import MsalConnectionManager + +connections = MsalConnectionManager( + config=Configuration( + connections={ + "SERVICE_CONNECTION": { + "settings": { + "auth_type": "ClientSecret", + "client_id": "your-client-id", + "tenant_id": "your-tenant-id", + "client_secret": "your-client-secret", + } + } + } + ) +) +settings = connections.get_default_connection().config +``` + +Load credentials from your application's secure configuration rather than hardcoding +them. Legacy constructor calls and the `provider.configuration` compatibility view +remain supported. Token credentials reuse the provider's bound settings. + ## Authentication Types The M365 Agents SDK in Python supports the following Auth types: ```python 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 e1338972e..c54857a63 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 @@ -62,6 +62,7 @@ from .authorization.claims_identity import ClaimsIdentity from .authorization.jwt.jwt_token_validator import JwtTokenValidator from .authorization.auth_types import AuthTypes +from .authorization.connection_settings_base import ConnectionSettingsBase # Client API from .client.agent_conversation_reference import AgentConversationReference @@ -114,6 +115,7 @@ # Define the package's public interface __all__ = [ + "ConnectionSettingsBase", "ActivityHandler", "Agent", "CardFactory", 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 200ed628f..d88c1890b 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 @@ -10,6 +10,7 @@ from .claims_identity import ClaimsIdentity from .jwt import JwtTokenValidator from .auth_types import AuthTypes +from .connection_settings_base import ConnectionSettingsBase __all__ = [ "AccessTokenProviderBase", @@ -21,4 +22,5 @@ "ClaimsIdentity", "JwtTokenValidator", "AuthTypes", + "ConnectionSettingsBase", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_log_config.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_log_config.py index 9f953ea37..cda257e11 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_log_config.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_log_config.py @@ -7,8 +7,10 @@ from urllib.parse import urlparse from microsoft_agents.activity._model_utils import pick_model_dict, SkipNone +from microsoft_agents.activity.config import Configuration, xpath from .agent_auth_configuration import AgentAuthConfiguration +from .connections.connections_map import _ConnectionMap _REDACTION_PEEK_LENGTH = 3 _REDACTION_THRESH = _REDACTION_PEEK_LENGTH + 6 @@ -90,7 +92,9 @@ def _redact_url_or_none(url: str | None) -> str | None: return _redact_url(url) -def _summarize_auth_configs(config_map: dict[str, AgentAuthConfiguration]) -> str: +def _summarize_auth_configs( + config_map: dict[str, AgentAuthConfiguration | Configuration], +) -> str: """ Summarize the authentication configuration for logging. @@ -101,31 +105,84 @@ def _summarize_auth_configs(config_map: dict[str, AgentAuthConfiguration]) -> st """ summary = [] for connection_name, config in config_map.items(): + scopes = ( + config.SCOPES + if isinstance(config, AgentAuthConfiguration) + else ( + config.get_scopes(xpath("scopes")) + if config.get(xpath("scopes")) is not None + else None + ) + ) + if isinstance(config, AgentAuthConfiguration): + config = Configuration(vars(config)) summary.append( pick_model_dict( CONNECTION=connection_name, - CONNECTION_NAME=SkipNone(config.CONNECTION_NAME), - CLIENTID=SkipNone(_redact_str_or_none(config.CLIENT_ID, peek=True)), - TENANTID=SkipNone(_redact_str_or_none(config.TENANT_ID, peek=True)), - CLIENTSECRET=SkipNone(_redact_str_or_none(config.CLIENT_SECRET)), - AUTHORITY=SkipNone(_redact_url_or_none(config.AUTHORITY)), - SCOPES=SkipNone(_redact_scopes(config.SCOPES)), + CONNECTION_NAME=SkipNone(config.get_strn(xpath("connection_name"))), + CLIENTID=SkipNone( + _redact_str_or_none(config.get_strn(xpath("client_id")), peek=True) + ), + TENANTID=SkipNone( + _redact_str_or_none(config.get_strn(xpath("tenant_id")), peek=True) + ), + CLIENTSECRET=SkipNone( + _redact_str_or_none(config.get_strn(xpath("client_secret"))) + ), + AUTHORITY=SkipNone( + _redact_url_or_none( + config.get_strn(xpath("authority"), xpath("authorityendpoint")) + ) + ), + SCOPES=SkipNone(_redact_scopes(scopes)), FEDERATED_CLIENT_ID=SkipNone( - _redact_str_or_none(config.FEDERATED_CLIENT_ID, peek=True) + _redact_str_or_none( + config.get_strn(xpath("federated_client_id")), peek=True + ) + ), + CERT_PFX_FILE=SkipNone( + _redact_str_or_none(config.get_strn(xpath("cert_pfx_file"))) ), - CERT_PFX_FILE=SkipNone(_redact_str_or_none(config.CERT_PFX_FILE)), ALT_BLUEPRINT_ID=SkipNone( - _redact_str_or_none(config.ALT_BLUEPRINT_ID, peek=True) + _redact_str_or_none( + config.get_strn( + xpath("alternate_blueprint_connection_name"), + xpath("alt_blueprint_id"), + xpath("alt_blueprint_name"), + ), + peek=True, + ) + ), + IDPM_RESOURCE=SkipNone( + _redact_url_or_none(config.get_strn(xpath("idpm_resource"))) + ), + AZURE_REGION=SkipNone( + _redact_url_or_none( + config.get_strn( + xpath("azure_region"), xpath("regional_authority") + ) + ) + ), + ANONYMOUS_ALLOWED=str( + config.get_bool(xpath("anonymous_allowed"), default=False) ), - IDPM_RESOURCE=SkipNone(_redact_url_or_none(config.IDPM_RESOURCE)), - AZURE_REGION=SkipNone(_redact_url_or_none(config.AZURE_REGION)), - ANONYMOUS_ALLOWED=str(config.ANONYMOUS_ALLOWED), ) ) return json.dumps(summary, indent=2) -def _summarize_connections_map(connections_map: list[dict[str, str]]) -> str: +def _summarize_connections_map( + connections_map: list[dict[str, str]] | _ConnectionMap, +) -> str: + if isinstance(connections_map, _ConnectionMap): + connections_map = [ + { + "CONNECTION": item.connection, + "AUDIENCE": item.audience, + "SERVICEURL": item.service_url, + } + for item in connections_map + ] connections_map_output = [] for mapping in connections_map: obj = { @@ -148,8 +205,8 @@ def _summarize_connections_map(connections_map: list[dict[str, str]]) -> str: def _log_config( logger: Logger, - config_map: dict[str, AgentAuthConfiguration], - connections_map: list[dict[str, str]], + config_map: dict[str, AgentAuthConfiguration | Configuration], + connections_map: list[dict[str, str]] | _ConnectionMap, ) -> None: """ Log the configuration of the MSAL connection manager. diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py index 250c9c999..6c19bee4f 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/access_token_provider_base.py @@ -1,18 +1,20 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Protocol +from __future__ import annotations + +from typing import Protocol, TYPE_CHECKING from abc import abstractmethod from azure.core.credentials_async import AsyncTokenCredential -from .agent_auth_configuration import AgentAuthConfiguration +if TYPE_CHECKING: + from .connections import AgentAuthConfiguration, ConnectionSettingsBase class AccessTokenProviderBase(Protocol): @property - @abstractmethod def configuration(self) -> AgentAuthConfiguration: """ The configuration for the access token provider. @@ -21,6 +23,13 @@ def configuration(self) -> AgentAuthConfiguration: """ raise NotImplementedError() + @property + def config(self) -> ConnectionSettingsBase: + """Bound connection settings, with a compatibility path for legacy providers.""" + from .connections import ConnectionSettingsBase + + return ConnectionSettingsBase._from_agent_auth_config(self.configuration) + @abstractmethod async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py index ad188d50d..a52bcce78 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py @@ -1,335 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from __future__ import annotations +from .connections.agent_auth_configuration import AgentAuthConfiguration -from typing import Any - -from microsoft_agents.activity.config._coercion import coerce_bool - -from microsoft_agents.hosting.core.authorization.auth_types import AuthTypes -from microsoft_agents.hosting.core.authorization._entra_issuers import ( - default_connection_issuers, -) - -# Env-style configuration keys that ``__init__`` recognizes and binds into -# first-class fields (via the ``kwargs.get("...")`` aliases below). These are -# excluded from ``provider_settings`` so that core config — including sensitive -# values like ``CLIENTSECRET`` — is never duplicated into the provider bag. -_RECOGNIZED_CONFIG_KEYS = frozenset( - { - "AUTHTYPE", - "CLIENTID", - "AUTHORITY", - "AUTHORITYENDPOINT", - "TENANTID", - "CLIENTSECRET", - "CERTPFXFILE", - "SENDX5C", - "CONNECTIONNAME", - "FEDERATEDCLIENTID", - "FEDERATEDTOKENFILE", - "SCOPES", - "AZUREREGION", - "REGIONALAUTHORITY", - "IDPMRESOURCE", - "ALT_BLUEPRINT_NAME", - "ALTERNATEBLUEPRINTCONNECTIONNAME", - "ANONYMOUS_ALLOWED", - "ISSUERS", - "VALIDATE_ISSUER", - } -) - - -def _normalize_issuers(value: Any) -> list[str] | None: - if value is None: - return None - if isinstance(value, str): - values = value.replace(",", " ").split() - elif isinstance(value, dict): - values = value.values() - else: - values = value - issuers = [str(item).strip() for item in values if item and str(item).strip()] - return issuers or None - - -class AgentAuthConfiguration: - """ - Configuration for Agent authentication. - - TENANT_ID: The tenant ID for the Azure AD. - CLIENT_ID: The client ID for the Azure AD application. - AUTH_TYPE: The type of authentication to use (microsoft_agents.hosting.core.authorization.auth_types.AuthTypes). - CLIENT_SECRET: The client secret for the Azure AD application (if using client secret authentication). - CERT_PFX_FILE: The path to the PFX certificate file (if using certificate authentication). - SEND_X5C: Whether to include the public certificate in the x5c header for certificate authentication. - CONNECTION_NAME: The name of the connection - FEDERATED_CLIENT_ID: The client ID for federated credentials (if using federated credentials authentication). - SCOPES: The scopes to request - AUTHORITY: The authority URL for the Azure AD (if different from the default). - ALT_BLUEPRINT_ID: An optional alternative blueprint ID used when constructing a connector client. - AZURE_REGION: The Azure regional token service to use for token acquisition (ESTS-R). - This feature is currently available to first-party applications only. - IDPM_RESOURCE: The resource URL for Identity Proxy Manager (IDPM) token acquisition. - Only meaningful when AUTH_TYPE is AuthTypes.identity_proxy_manager. When not set, - it defaults to "api://AzureAdTokenExchange/.default". - ISSUERS: An optional explicit list of accepted token issuers. When not provided, - a cloud/tenant-scoped default is computed from TENANT_ID and AUTHORITY. - VALIDATE_ISSUER: Explicit opt-in flag (default False) that enables issuer - allow-list validation in JwtTokenValidator. Preserved as an opt-in for - backward compatibility: existing deployments are unaffected unless - they explicitly enable it. Note: tid-to-issuer binding is always - enforced by JwtTokenValidator (per issue #626) whenever the verified - token's issuer is a recognized Entra issuer with a GUID tenant, - regardless of this flag. - ANONYMOUS_ALLOWED: Whether anonymous access is allowed (default False). - FEDERATED_TOKEN_FILE: The path to the federated token file (if using federated credentials authentication). - """ - - TENANT_ID: str | None - AUTH_TYPE: AuthTypes - CLIENT_ID: str | None - CLIENT_SECRET: str | None - CERT_PFX_FILE: str | None - SEND_X5C: bool = False - CONNECTION_NAME: str | None - FEDERATED_CLIENT_ID: str | None - SCOPES: list[str] | None - AUTHORITY: str | None - ALT_BLUEPRINT_ID: str | None - AZURE_REGION: str | None - IDPM_RESOURCE: str | None - ANONYMOUS_ALLOWED: bool = False - VALIDATE_ISSUER: bool = False - FEDERATED_TOKEN_FILE: str | None - - # Provider-specific settings that aren't first-class fields (e.g. the Entra - # sidecar's SERVICE_NAME, SIDECAR_BASE_URL). Preserved here as a single dict - # rather than as dynamic attributes so the extra surface is explicit and - # discoverable. Custom providers read these via ``provider_settings``. - provider_settings: dict[str, Any] - - # Multi-connection support: Maintains a map of all configured connections - # to enable JWT validation across connections. This allows tokens issued - # for any configured connection to be validated, supporting multi-tenant - # scenarios where connections share a security boundary. - # - # Note: This is an internal implementation detail. External code should - # not directly access _connections. - _connections: dict[str, AgentAuthConfiguration] - - def __init__( - self, - auth_type: AuthTypes | None = None, - client_id: str | None = None, - tenant_id: str | None = None, - client_secret: str | None = None, - *, - cert_pfx_file: str | None = None, - connection_name: str | None = None, - federated_client_id: str | None = None, - authority: str | None = None, - scopes: list[str] | None = None, - azure_region: str | None = None, - idpm_resource: str | None = None, - anonymous_allowed: bool | None = None, - issuers: list[str] | None = None, - validate_issuer: bool | None = None, - federated_token_file: str | None = None, - send_x5c: bool | None = None, - **kwargs: Any, - ): - - self.AUTH_TYPE = auth_type or kwargs.get("AUTHTYPE", AuthTypes.client_secret) - self.CLIENT_ID = client_id or kwargs.get("CLIENTID", None) - # .NET binds the authority from the "AuthorityEndpoint" configuration key; - # accept it as an alias for parity while keeping the existing "AUTHORITY" key. - self.AUTHORITY = ( - authority - or kwargs.get("AUTHORITY", None) - or kwargs.get("AUTHORITYENDPOINT", None) - ) - self.TENANT_ID = tenant_id or kwargs.get("TENANTID", None) - self.CLIENT_SECRET = client_secret or kwargs.get("CLIENTSECRET", None) - self.CERT_PFX_FILE = cert_pfx_file or kwargs.get("CERTPFXFILE", None) - self.SEND_X5C = coerce_bool( - send_x5c if send_x5c is not None else kwargs.get("SENDX5C", False), - default=False, - name="SENDX5C", - ) - self.CONNECTION_NAME = connection_name or kwargs.get("CONNECTIONNAME", None) - self.FEDERATED_CLIENT_ID = federated_client_id or kwargs.get( - "FEDERATEDCLIENTID", None - ) - self.FEDERATED_TOKEN_FILE = federated_token_file or kwargs.get( - "FEDERATEDTOKENFILE", None - ) - self.SCOPES = scopes or kwargs.get("SCOPES", None) - # Azure regional token service. Falls back to the legacy "REGIONALAUTHORITY" - # configuration key when "AZUREREGION" is not provided. - self.AZURE_REGION = ( - azure_region - or kwargs.get("AZUREREGION", None) - or kwargs.get("REGIONALAUTHORITY", None) - ) - # Resource URL for Identity Proxy Manager (IDPM) token acquisition. - # Only meaningful when AUTH_TYPE is AuthTypes.identity_proxy_manager. - self.IDPM_RESOURCE = idpm_resource or kwargs.get("IDPMRESOURCE", None) - # .NET names this "AlternateBlueprintConnectionName"; accept that key as an - # alias for the existing "ALT_BLUEPRINT_NAME" without removing the latter. - self.ALT_BLUEPRINT_ID = kwargs.get("ALT_BLUEPRINT_NAME", None) or kwargs.get( - "ALTERNATEBLUEPRINTCONNECTIONNAME", None - ) - # Env values arrive as strings, so coerce explicitly: ``bool("false")`` - # would be ``True`` and silently enable anonymous auth when configured - # off. Coercion is fail-safe (unrecognized -> False). An explicitly - # provided ``anonymous_allowed`` (including ``False``) takes precedence - # over the ``ANONYMOUS_ALLOWED`` kwarg; ``None`` means "not provided". - self.ANONYMOUS_ALLOWED = coerce_bool( - ( - anonymous_allowed - if anonymous_allowed is not None - else kwargs.get("ANONYMOUS_ALLOWED", False) - ), - default=False, - name="ANONYMOUS_ALLOWED", - ) - - # Explicit, optional issuer allow-list. When not provided, ISSUERS falls - # back to a cloud/tenant-scoped default (see the ISSUERS property below). - self._configured_issuers = _normalize_issuers( - issuers if issuers is not None else kwargs.get("ISSUERS", None) - ) - # Explicit opt-in for issuer allow-list validation in JwtTokenValidator. - # Off by default so existing deployments are unaffected unless they - # explicitly enable it. Note: tid-to-issuer binding is always enforced - # by JwtTokenValidator regardless of this flag (see VALIDATE_ISSUER - # docstring above). Same fail-safe string coercion as ANONYMOUS_ALLOWED - # applies here. - self.VALIDATE_ISSUER = coerce_bool( - ( - validate_issuer - if validate_issuer is not None - else kwargs.get("VALIDATE_ISSUER", False) - ), - default=False, - name="VALIDATE_ISSUER", - ) - - # Preserve genuinely provider-specific settings that aren't first-class - # fields (e.g. the Entra sidecar's SERVICE_NAME, SIDECAR_BASE_URL) so - # custom providers can read them via ``provider_settings``. Recognized - # core alias keys (bound into first-class fields above) are excluded so - # core config — including ``CLIENTSECRET`` — is never copied into this bag. - self.provider_settings = { - key: value - for key, value in kwargs.items() - if not hasattr(self, key) and key not in _RECOGNIZED_CONFIG_KEYS - } - - # JWT-patch: always at least include self for backward compat - self._connections = {str(self.CONNECTION_NAME): self} - - self._validate() - - def _validate(self) -> None: - """ - Validates the configuration. Raises ValueError if any required fields are missing or invalid. - """ - if self.AUTH_TYPE == AuthTypes.certificate and not self.CERT_PFX_FILE: - raise ValueError( - "CERT_PFX_FILE is required for certificate authentication." - ) - if ( - self.AUTH_TYPE == AuthTypes.federated_credentials - and not self.FEDERATED_CLIENT_ID - ): - raise ValueError( - "FEDERATED_CLIENT_ID is required for federated_credentials authentication." - ) - if ( - self.AUTH_TYPE == AuthTypes.workload_identity - and not self.FEDERATED_TOKEN_FILE - ): - raise ValueError( - "FEDERATED_TOKEN_FILE is required for workload_identity authentication." - ) - - @property - def ISSUERS(self) -> list[str]: - """ - Gets the list of accepted issuers: the explicitly configured list when - provided, otherwise a cloud/tenant-scoped default derived from the - effective tenant (the tenant segment embedded in AUTHORITY's path when - present, e.g. ``https://login.microsoftonline.com/common`` or - ``.../{tenant-guid}``, otherwise TENANT_ID) and AUTHORITY (US - Government authorities yield US Government issuer defaults). - """ - if self._configured_issuers: - return list(self._configured_issuers) - return default_connection_issuers(self.TENANT_ID, self.AUTHORITY) - - # .NET-aligned, read-only property aliases. These mirror the property names on - # the .NET ``ConnectionSettingsBase`` so provider code and cross-language readers - # can use a consistent snake_case surface. They are thin views over the existing - # UPPER_SNAKE attributes and do not change how configuration is stored. - @property - def client_id(self) -> str | None: - """Alias for :attr:`CLIENT_ID` (.NET ``ClientId``).""" - return self.CLIENT_ID - - @property - def authority(self) -> str | None: - """Alias for :attr:`AUTHORITY` (.NET ``AuthorityEndpoint``).""" - return self.AUTHORITY - - @property - def tenant_id(self) -> str | None: - """Alias for :attr:`TENANT_ID` (.NET ``TenantId``).""" - return self.TENANT_ID - - @property - def scopes(self) -> list[str] | None: - """Alias for :attr:`SCOPES` (.NET ``Scopes``).""" - return self.SCOPES - - @property - def alternate_blueprint_connection_name(self) -> str | None: - """Alias for :attr:`ALT_BLUEPRINT_ID` (.NET ``AlternateBlueprintConnectionName``).""" - return self.ALT_BLUEPRINT_ID - - def _jwt_patch_is_valid_aud(self, aud: Any) -> bool: - """ - JWT-patch: Checks if the given audience is valid for any of the - connections. A non-string ``aud`` (e.g. the JWT-spec-permitted array - form, or a malformed numeric/object claim) is never valid: only a - single string audience is accepted, so this returns ``False`` rather - than raising, letting the caller reject it as an invalid audience. - """ - if not isinstance(aud, str): - return False - for conn in self._connections.values(): - if not conn.CLIENT_ID: - continue - if aud.lower() == conn.CLIENT_ID.lower(): - return True - return False - - def _jwt_patch_find_connection(self, aud: Any) -> "AgentAuthConfiguration | None": - """ - JWT-patch: Finds the configured connection whose CLIENT_ID matches the - given audience (case-insensitive), so JwtTokenValidator can route JWKS - lookup and issuer validation to the correct connection's tenant/authority - in multi-connection setups. Returns None when no connection matches, - or when ``aud`` is not a string (e.g. an unverified array-form or - malformed claim) -- callers fall back to default routing rather than - failing. - """ - if not aud or not isinstance(aud, str): - return None - for conn in self._connections.values(): - if conn.CLIENT_ID and aud.lower() == conn.CLIENT_ID.lower(): - return conn - return None +__all__ = ["AgentAuthConfiguration"] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py index 8be0fb2cf..1603a7a02 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py @@ -7,7 +7,7 @@ from azure.core.credentials_async import AsyncTokenCredential from .access_token_provider_base import AccessTokenProviderBase -from .agent_auth_configuration import AgentAuthConfiguration +from .connections import AgentAuthConfiguration, ConnectionSettingsBase from ._helpers import _CallableTokenCredential _ANONYMOUS_TOKEN_EXPIRATION = 2**31 - 1 @@ -61,3 +61,7 @@ async def get_agentic_user_token( scopes: list[str], ) -> Optional[str]: return "" + + @property + def config(self) -> ConnectionSettingsBase: + return ConnectionSettingsBase() diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_manager.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_manager.py index 4b5b11a42..de493ff93 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_manager.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_manager.py @@ -1,224 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import re -import logging +from .connections.connection_manager import ConnectionManager -from collections.abc import Callable - -from microsoft_agents.activity import Activity - -from .agent_auth_configuration import AgentAuthConfiguration -from .access_token_provider_base import AccessTokenProviderBase -from .claims_identity import ClaimsIdentity -from .connections import Connections - -from ._log_config import _log_config - -logger = logging.getLogger(__name__) - - -class ConnectionManager(Connections): - """ - Generic, provider-agnostic connection manager. - - Dispatches to any :class:`AccessTokenProviderBase` implementation. The - ``provider_factory`` callable determines which auth provider is instantiated - for each connection configuration, so new auth providers (MSAL, Entra ID - sidecar, future providers) can reuse the connection routing logic without - duplicating it. - """ - - _connections: dict[str, AccessTokenProviderBase] - _connections_map: list[dict[str, str]] - _config_map: dict[str, AgentAuthConfiguration] - - def __init__( - self, - provider_factory: Callable[[AgentAuthConfiguration], AccessTokenProviderBase], - connections_configurations: dict[str, AgentAuthConfiguration] | None = None, - connections_map: list[dict[str, str]] | None = None, - **kwargs, - ): - """ - Initialize the connection manager. - - :param provider_factory: Factory that creates an - :class:`microsoft_agents.hosting.core.AccessTokenProviderBase` from an - :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`. - :type provider_factory: Callable[[AgentAuthConfiguration], AccessTokenProviderBase] - :param connections_configurations: A dictionary of connection configurations. - :type connections_configurations: dict[str, :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`] - :param connections_map: A list of connection mappings. - :type connections_map: list[dict[str, str]] - :raises ValueError: If no service connection configuration is provided. - """ - - self._provider_factory = provider_factory - self._connections = {} - # ``load_configuration_from_env`` yields CONNECTIONSMAP as a list when it - # is configured (it collapses the parsed name->entry dict to a list) and - # as an empty dict ({}) only when unset. Normalize to a list so the guard - # also handles a raw dict passed via direct/manual construction and the - # empty-unset case, keeping the runtime shape consistent with the - # list[dict[str, str]] the class expects everywhere else. - raw_connections_map = ( - connections_map - if connections_map is not None - else kwargs.get("CONNECTIONSMAP", []) - ) - - if isinstance(raw_connections_map, dict): - raw_connections_map = list(raw_connections_map.values()) - self._connections_map = raw_connections_map or [] - self._config_map = {} - - if connections_configurations: - for ( - connection_name, - agent_auth_config, - ) in connections_configurations.items(): - self._connections[connection_name] = provider_factory(agent_auth_config) - self._config_map[connection_name] = agent_auth_config - else: - raw_configurations: dict[str, dict] = kwargs.get("CONNECTIONS", {}) - for connection_name, connection_settings in raw_configurations.items(): - parsed_configuration = AgentAuthConfiguration( - **connection_settings.get("SETTINGS", {}) - ) - self._connections[connection_name] = provider_factory( - parsed_configuration - ) - self._config_map[connection_name] = parsed_configuration - - # JWT-patch - for connection_name, config in self._config_map.items(): - config._connections = self._config_map - - if not self._connections.get("SERVICE_CONNECTION", None): - raise ValueError("No service connection configuration provided.") - - _log_config(logger, self._config_map, self._connections_map) - - def get_connection(self, connection_name: str | None) -> AccessTokenProviderBase: - """ - Get the OAuth connection for the agent. - - :param connection_name: The name of the connection. - :type connection_name: str | None - :return: The OAuth connection for the agent. - :rtype: :class:`microsoft_agents.hosting.core.AccessTokenProviderBase` - """ - original_name = connection_name - connection_name = connection_name or "SERVICE_CONNECTION" - connection = self._connections.get(connection_name, None) - if not connection: - if original_name: - raise ValueError(f"No connection found for '{original_name}'.") - else: - raise ValueError( - "No default service connection found. Expected 'SERVICE_CONNECTION'." - ) - return connection - - def get_default_connection(self) -> AccessTokenProviderBase: - """ - Get the default OAuth connection for the agent. - - :return: The default OAuth connection for the agent. - :rtype: :class:`microsoft_agents.hosting.core.AccessTokenProviderBase` - """ - connection = self._connections.get("SERVICE_CONNECTION", None) - if not connection: - raise ValueError( - "No default service connection found. Expected 'SERVICE_CONNECTION'." - ) - return connection - - @staticmethod - def _service_url_matches(pattern: str, service_url: str) -> bool: - """Return whether a SERVICEURL map pattern matches the service URL. - - ``"*"``/empty match any URL. Otherwise the pattern is treated as a regex - and matched with an unanchored search, mirroring the .NET - ``ConfigurationConnections`` which uses ``Regex.Match``. - - :raises ValueError: If the pattern is not a valid regex. - """ - if pattern == "*" or pattern == "": - return True - try: - return re.search(pattern, service_url, re.IGNORECASE) is not None - except re.error as exc: - raise ValueError( - f"Invalid SERVICEURL regex '{pattern}' in connections map: {exc}" - ) from exc - - def get_token_provider( - self, claims_identity: ClaimsIdentity, service_url: str - ) -> AccessTokenProviderBase: - """ - Get the OAuth token provider for the agent. - - :param claims_identity: The claims identity of the bot. - :type claims_identity: :class:`microsoft_agents.hosting.core.ClaimsIdentity` - :param service_url: The service URL of the bot. - :type service_url: str - :return: The OAuth token provider for the agent. - :rtype: :class:`microsoft_agents.hosting.core.AccessTokenProviderBase` - :raises ValueError: If no connection is found for the given audience and service URL. - """ - if not claims_identity or not service_url: - raise ValueError( - "Claims identity and Service URL are required to get the token provider." - ) - - if not self._connections_map: - return self.get_default_connection() - - aud = claims_identity.get_app_id() or "" - for item in self._connections_map: - audience_match = True - item_aud = item.get("AUDIENCE", "") - if item_aud: - audience_match = item_aud.lower() == aud.lower() - - if audience_match and self._service_url_matches( - item.get("SERVICEURL", ""), service_url - ): - return self.get_connection(item.get("CONNECTION")) - - raise ValueError( - f"No connection found for audience '{aud}' and serviceUrl '{service_url}'." - ) - - def get_token_provider_from_activity( - self, - claims_identity: ClaimsIdentity, - activity: Activity, - ) -> AccessTokenProviderBase: - """ - Get the OAuth token provider for the agent from an activity. - - :param claims_identity: The claims identity of the agent. - :param activity: The activity from which to get the token provider. - :return: The access token provider for the agent. - """ - provider = self.get_token_provider(claims_identity, activity.service_url) - if activity.is_agentic_request() and provider.configuration.ALT_BLUEPRINT_ID: - provider = self.get_connection(provider.configuration.ALT_BLUEPRINT_ID) - return provider - - def get_default_connection_configuration(self) -> AgentAuthConfiguration: - """ - Get the default connection configuration for the agent. - - :return: The default connection configuration for the agent. - :rtype: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` - """ - config = self._config_map.get("SERVICE_CONNECTION") - if not config: - raise ValueError( - "No default service connection configuration found. Expected 'SERVICE_CONNECTION'." - ) - return config +__all__ = ["ConnectionManager"] 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..1b08e7b07 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 @@ -1,195 +1,9 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from __future__ import annotations +from .connections.connection_settings_base import ( + ConnectionSettingsBase, + _ConnectionSettingsBase, +) -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 - - -def _read(configuration: Any, *names: str, default: Any = None) -> Any: - """Read the first populated attribute from ``configuration``. - - Tolerates both the SDK's upper-case configuration keys (e.g. ``CLIENT_ID``) - and the snake_case property aliases, so settings can be bound from an - :class:`AgentAuthConfiguration`, a raw mapping-like object, or another - settings instance. - """ - is_mapping = isinstance(configuration, Mapping) - for name in names: - value = ( - configuration.get(name) - if is_mapping - else getattr(configuration, name, None) - ) - if value is not None: - return value - return default - - -class _ConnectionSettingsBase: - """Common, provider-agnostic connection settings shared by all providers. - - Mirrors the .NET ``Microsoft.Agents.Authentication.ConnectionSettingsBase`` - abstract base. Provider-specific settings classes (for example the Entra - sidecar's :class:`SidecarConnectionSettings`) extend this with their own - fields, enabling an extensible, connection-type-based configuration model - that matches the .NET provider pattern. - - Instances are normally produced from an :class:`AgentAuthConfiguration` - (the raw, provider-agnostic configuration bag) via - :meth:`from_configuration`. Subclasses bind their own fields and merge the - common base fields via :meth:`base_kwargs_from_configuration`. - - Property names are the Python snake_case equivalents of the .NET - ``ConnectionSettingsBase`` properties: - - ==================================== ==================================== - .NET Python - ==================================== ==================================== - ``ClientId`` ``client_id`` - ``AuthorityEndpoint`` ``authority`` - ``TenantId`` ``tenant_id`` - ``Scopes`` ``scopes`` - ``AlternateBlueprintConnectionName`` ``alternate_blueprint_connection_name`` - ==================================== ==================================== - """ - - def __init__( - self, - *, - client_id: str | None = None, - authority: str | None = None, - tenant_id: str | None = None, - scopes: list[str] | None = None, - alternate_blueprint_connection_name: str | None = None, - ): - self.client_id = client_id - self.authority = authority - self.tenant_id = tenant_id - self.scopes = scopes - self.alternate_blueprint_connection_name = alternate_blueprint_connection_name - - @staticmethod - def base_kwargs_from_configuration( - configuration: "AgentAuthConfiguration", - ) -> dict: - """Bind the common base fields from an :class:`AgentAuthConfiguration`. - - Returns a kwargs dict suitable for passing to ``__init__``. Subclasses - call this and merge it with their own provider-specific kwargs so the - base fields stay defined in a single place. - """ - # Accept both the underscored configuration keys and the SDK's env-style - # keys (e.g. ``load_configuration_from_env`` yields ``CLIENTID`` / - # ``TENANTID`` without underscores) so binding from a raw ``SETTINGS`` - # mapping doesn't silently drop values. - return dict( - client_id=_read(configuration, "CLIENT_ID", "CLIENTID", "client_id"), - authority=_read( - configuration, "AUTHORITY", "AUTHORITYENDPOINT", "authority" - ), - tenant_id=_read(configuration, "TENANT_ID", "TENANTID", "tenant_id"), - scopes=_read(configuration, "SCOPES", "scopes"), - alternate_blueprint_connection_name=_read( - configuration, - "ALT_BLUEPRINT_ID", - "ALT_BLUEPRINT_NAME", - "ALTERNATEBLUEPRINTCONNECTIONNAME", - "alternate_blueprint_connection_name", - ), - ) - - @classmethod - def from_configuration( - cls, configuration: "AgentAuthConfiguration" - ) -> "_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) +__all__ = ["ConnectionSettingsBase", "_ConnectionSettingsBase"] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/__init__.py new file mode 100644 index 000000000..3104368f1 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent_auth_configuration import AgentAuthConfiguration +from .connection_settings_base import ConnectionSettingsBase +from .connections import Connections +from .connection_manager import ConnectionManager + +__all__ = [ + "AgentAuthConfiguration", + "ConnectionSettingsBase", + "Connections", + "ConnectionManager", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/agent_auth_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/agent_auth_configuration.py new file mode 100644 index 000000000..ad188d50d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/agent_auth_configuration.py @@ -0,0 +1,335 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from typing import Any + +from microsoft_agents.activity.config._coercion import coerce_bool + +from microsoft_agents.hosting.core.authorization.auth_types import AuthTypes +from microsoft_agents.hosting.core.authorization._entra_issuers import ( + default_connection_issuers, +) + +# Env-style configuration keys that ``__init__`` recognizes and binds into +# first-class fields (via the ``kwargs.get("...")`` aliases below). These are +# excluded from ``provider_settings`` so that core config — including sensitive +# values like ``CLIENTSECRET`` — is never duplicated into the provider bag. +_RECOGNIZED_CONFIG_KEYS = frozenset( + { + "AUTHTYPE", + "CLIENTID", + "AUTHORITY", + "AUTHORITYENDPOINT", + "TENANTID", + "CLIENTSECRET", + "CERTPFXFILE", + "SENDX5C", + "CONNECTIONNAME", + "FEDERATEDCLIENTID", + "FEDERATEDTOKENFILE", + "SCOPES", + "AZUREREGION", + "REGIONALAUTHORITY", + "IDPMRESOURCE", + "ALT_BLUEPRINT_NAME", + "ALTERNATEBLUEPRINTCONNECTIONNAME", + "ANONYMOUS_ALLOWED", + "ISSUERS", + "VALIDATE_ISSUER", + } +) + + +def _normalize_issuers(value: Any) -> list[str] | None: + if value is None: + return None + if isinstance(value, str): + values = value.replace(",", " ").split() + elif isinstance(value, dict): + values = value.values() + else: + values = value + issuers = [str(item).strip() for item in values if item and str(item).strip()] + return issuers or None + + +class AgentAuthConfiguration: + """ + Configuration for Agent authentication. + + TENANT_ID: The tenant ID for the Azure AD. + CLIENT_ID: The client ID for the Azure AD application. + AUTH_TYPE: The type of authentication to use (microsoft_agents.hosting.core.authorization.auth_types.AuthTypes). + CLIENT_SECRET: The client secret for the Azure AD application (if using client secret authentication). + CERT_PFX_FILE: The path to the PFX certificate file (if using certificate authentication). + SEND_X5C: Whether to include the public certificate in the x5c header for certificate authentication. + CONNECTION_NAME: The name of the connection + FEDERATED_CLIENT_ID: The client ID for federated credentials (if using federated credentials authentication). + SCOPES: The scopes to request + AUTHORITY: The authority URL for the Azure AD (if different from the default). + ALT_BLUEPRINT_ID: An optional alternative blueprint ID used when constructing a connector client. + AZURE_REGION: The Azure regional token service to use for token acquisition (ESTS-R). + This feature is currently available to first-party applications only. + IDPM_RESOURCE: The resource URL for Identity Proxy Manager (IDPM) token acquisition. + Only meaningful when AUTH_TYPE is AuthTypes.identity_proxy_manager. When not set, + it defaults to "api://AzureAdTokenExchange/.default". + ISSUERS: An optional explicit list of accepted token issuers. When not provided, + a cloud/tenant-scoped default is computed from TENANT_ID and AUTHORITY. + VALIDATE_ISSUER: Explicit opt-in flag (default False) that enables issuer + allow-list validation in JwtTokenValidator. Preserved as an opt-in for + backward compatibility: existing deployments are unaffected unless + they explicitly enable it. Note: tid-to-issuer binding is always + enforced by JwtTokenValidator (per issue #626) whenever the verified + token's issuer is a recognized Entra issuer with a GUID tenant, + regardless of this flag. + ANONYMOUS_ALLOWED: Whether anonymous access is allowed (default False). + FEDERATED_TOKEN_FILE: The path to the federated token file (if using federated credentials authentication). + """ + + TENANT_ID: str | None + AUTH_TYPE: AuthTypes + CLIENT_ID: str | None + CLIENT_SECRET: str | None + CERT_PFX_FILE: str | None + SEND_X5C: bool = False + CONNECTION_NAME: str | None + FEDERATED_CLIENT_ID: str | None + SCOPES: list[str] | None + AUTHORITY: str | None + ALT_BLUEPRINT_ID: str | None + AZURE_REGION: str | None + IDPM_RESOURCE: str | None + ANONYMOUS_ALLOWED: bool = False + VALIDATE_ISSUER: bool = False + FEDERATED_TOKEN_FILE: str | None + + # Provider-specific settings that aren't first-class fields (e.g. the Entra + # sidecar's SERVICE_NAME, SIDECAR_BASE_URL). Preserved here as a single dict + # rather than as dynamic attributes so the extra surface is explicit and + # discoverable. Custom providers read these via ``provider_settings``. + provider_settings: dict[str, Any] + + # Multi-connection support: Maintains a map of all configured connections + # to enable JWT validation across connections. This allows tokens issued + # for any configured connection to be validated, supporting multi-tenant + # scenarios where connections share a security boundary. + # + # Note: This is an internal implementation detail. External code should + # not directly access _connections. + _connections: dict[str, AgentAuthConfiguration] + + def __init__( + self, + auth_type: AuthTypes | None = None, + client_id: str | None = None, + tenant_id: str | None = None, + client_secret: str | None = None, + *, + cert_pfx_file: str | None = None, + connection_name: str | None = None, + federated_client_id: str | None = None, + authority: str | None = None, + scopes: list[str] | None = None, + azure_region: str | None = None, + idpm_resource: str | None = None, + anonymous_allowed: bool | None = None, + issuers: list[str] | None = None, + validate_issuer: bool | None = None, + federated_token_file: str | None = None, + send_x5c: bool | None = None, + **kwargs: Any, + ): + + self.AUTH_TYPE = auth_type or kwargs.get("AUTHTYPE", AuthTypes.client_secret) + self.CLIENT_ID = client_id or kwargs.get("CLIENTID", None) + # .NET binds the authority from the "AuthorityEndpoint" configuration key; + # accept it as an alias for parity while keeping the existing "AUTHORITY" key. + self.AUTHORITY = ( + authority + or kwargs.get("AUTHORITY", None) + or kwargs.get("AUTHORITYENDPOINT", None) + ) + self.TENANT_ID = tenant_id or kwargs.get("TENANTID", None) + self.CLIENT_SECRET = client_secret or kwargs.get("CLIENTSECRET", None) + self.CERT_PFX_FILE = cert_pfx_file or kwargs.get("CERTPFXFILE", None) + self.SEND_X5C = coerce_bool( + send_x5c if send_x5c is not None else kwargs.get("SENDX5C", False), + default=False, + name="SENDX5C", + ) + self.CONNECTION_NAME = connection_name or kwargs.get("CONNECTIONNAME", None) + self.FEDERATED_CLIENT_ID = federated_client_id or kwargs.get( + "FEDERATEDCLIENTID", None + ) + self.FEDERATED_TOKEN_FILE = federated_token_file or kwargs.get( + "FEDERATEDTOKENFILE", None + ) + self.SCOPES = scopes or kwargs.get("SCOPES", None) + # Azure regional token service. Falls back to the legacy "REGIONALAUTHORITY" + # configuration key when "AZUREREGION" is not provided. + self.AZURE_REGION = ( + azure_region + or kwargs.get("AZUREREGION", None) + or kwargs.get("REGIONALAUTHORITY", None) + ) + # Resource URL for Identity Proxy Manager (IDPM) token acquisition. + # Only meaningful when AUTH_TYPE is AuthTypes.identity_proxy_manager. + self.IDPM_RESOURCE = idpm_resource or kwargs.get("IDPMRESOURCE", None) + # .NET names this "AlternateBlueprintConnectionName"; accept that key as an + # alias for the existing "ALT_BLUEPRINT_NAME" without removing the latter. + self.ALT_BLUEPRINT_ID = kwargs.get("ALT_BLUEPRINT_NAME", None) or kwargs.get( + "ALTERNATEBLUEPRINTCONNECTIONNAME", None + ) + # Env values arrive as strings, so coerce explicitly: ``bool("false")`` + # would be ``True`` and silently enable anonymous auth when configured + # off. Coercion is fail-safe (unrecognized -> False). An explicitly + # provided ``anonymous_allowed`` (including ``False``) takes precedence + # over the ``ANONYMOUS_ALLOWED`` kwarg; ``None`` means "not provided". + self.ANONYMOUS_ALLOWED = coerce_bool( + ( + anonymous_allowed + if anonymous_allowed is not None + else kwargs.get("ANONYMOUS_ALLOWED", False) + ), + default=False, + name="ANONYMOUS_ALLOWED", + ) + + # Explicit, optional issuer allow-list. When not provided, ISSUERS falls + # back to a cloud/tenant-scoped default (see the ISSUERS property below). + self._configured_issuers = _normalize_issuers( + issuers if issuers is not None else kwargs.get("ISSUERS", None) + ) + # Explicit opt-in for issuer allow-list validation in JwtTokenValidator. + # Off by default so existing deployments are unaffected unless they + # explicitly enable it. Note: tid-to-issuer binding is always enforced + # by JwtTokenValidator regardless of this flag (see VALIDATE_ISSUER + # docstring above). Same fail-safe string coercion as ANONYMOUS_ALLOWED + # applies here. + self.VALIDATE_ISSUER = coerce_bool( + ( + validate_issuer + if validate_issuer is not None + else kwargs.get("VALIDATE_ISSUER", False) + ), + default=False, + name="VALIDATE_ISSUER", + ) + + # Preserve genuinely provider-specific settings that aren't first-class + # fields (e.g. the Entra sidecar's SERVICE_NAME, SIDECAR_BASE_URL) so + # custom providers can read them via ``provider_settings``. Recognized + # core alias keys (bound into first-class fields above) are excluded so + # core config — including ``CLIENTSECRET`` — is never copied into this bag. + self.provider_settings = { + key: value + for key, value in kwargs.items() + if not hasattr(self, key) and key not in _RECOGNIZED_CONFIG_KEYS + } + + # JWT-patch: always at least include self for backward compat + self._connections = {str(self.CONNECTION_NAME): self} + + self._validate() + + def _validate(self) -> None: + """ + Validates the configuration. Raises ValueError if any required fields are missing or invalid. + """ + if self.AUTH_TYPE == AuthTypes.certificate and not self.CERT_PFX_FILE: + raise ValueError( + "CERT_PFX_FILE is required for certificate authentication." + ) + if ( + self.AUTH_TYPE == AuthTypes.federated_credentials + and not self.FEDERATED_CLIENT_ID + ): + raise ValueError( + "FEDERATED_CLIENT_ID is required for federated_credentials authentication." + ) + if ( + self.AUTH_TYPE == AuthTypes.workload_identity + and not self.FEDERATED_TOKEN_FILE + ): + raise ValueError( + "FEDERATED_TOKEN_FILE is required for workload_identity authentication." + ) + + @property + def ISSUERS(self) -> list[str]: + """ + Gets the list of accepted issuers: the explicitly configured list when + provided, otherwise a cloud/tenant-scoped default derived from the + effective tenant (the tenant segment embedded in AUTHORITY's path when + present, e.g. ``https://login.microsoftonline.com/common`` or + ``.../{tenant-guid}``, otherwise TENANT_ID) and AUTHORITY (US + Government authorities yield US Government issuer defaults). + """ + if self._configured_issuers: + return list(self._configured_issuers) + return default_connection_issuers(self.TENANT_ID, self.AUTHORITY) + + # .NET-aligned, read-only property aliases. These mirror the property names on + # the .NET ``ConnectionSettingsBase`` so provider code and cross-language readers + # can use a consistent snake_case surface. They are thin views over the existing + # UPPER_SNAKE attributes and do not change how configuration is stored. + @property + def client_id(self) -> str | None: + """Alias for :attr:`CLIENT_ID` (.NET ``ClientId``).""" + return self.CLIENT_ID + + @property + def authority(self) -> str | None: + """Alias for :attr:`AUTHORITY` (.NET ``AuthorityEndpoint``).""" + return self.AUTHORITY + + @property + def tenant_id(self) -> str | None: + """Alias for :attr:`TENANT_ID` (.NET ``TenantId``).""" + return self.TENANT_ID + + @property + def scopes(self) -> list[str] | None: + """Alias for :attr:`SCOPES` (.NET ``Scopes``).""" + return self.SCOPES + + @property + def alternate_blueprint_connection_name(self) -> str | None: + """Alias for :attr:`ALT_BLUEPRINT_ID` (.NET ``AlternateBlueprintConnectionName``).""" + return self.ALT_BLUEPRINT_ID + + def _jwt_patch_is_valid_aud(self, aud: Any) -> bool: + """ + JWT-patch: Checks if the given audience is valid for any of the + connections. A non-string ``aud`` (e.g. the JWT-spec-permitted array + form, or a malformed numeric/object claim) is never valid: only a + single string audience is accepted, so this returns ``False`` rather + than raising, letting the caller reject it as an invalid audience. + """ + if not isinstance(aud, str): + return False + for conn in self._connections.values(): + if not conn.CLIENT_ID: + continue + if aud.lower() == conn.CLIENT_ID.lower(): + return True + return False + + def _jwt_patch_find_connection(self, aud: Any) -> "AgentAuthConfiguration | None": + """ + JWT-patch: Finds the configured connection whose CLIENT_ID matches the + given audience (case-insensitive), so JwtTokenValidator can route JWKS + lookup and issuer validation to the correct connection's tenant/authority + in multi-connection setups. Returns None when no connection matches, + or when ``aud`` is not a string (e.g. an unverified array-form or + malformed claim) -- callers fall back to default routing rather than + failing. + """ + if not aud or not isinstance(aud, str): + return None + for conn in self._connections.values(): + if conn.CLIENT_ID and aud.lower() == conn.CLIENT_ID.lower(): + return conn + return None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_manager.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_manager.py new file mode 100644 index 000000000..8facf824a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_manager.py @@ -0,0 +1,284 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +import logging + +from collections.abc import Callable +from typing import overload + +from microsoft_agents.activity import Activity +from microsoft_agents.activity.config import Configuration, xpath + +from .agent_auth_configuration import AgentAuthConfiguration +from ..access_token_provider_base import AccessTokenProviderBase +from ..claims_identity import ClaimsIdentity +from .connections import Connections +from .connections_map import _ConnectionMap + +from .._log_config import _log_config + +logger = logging.getLogger(__name__) + + +class ConnectionManager(Connections): + """ + Generic, provider-agnostic connection manager. + + Dispatches to any :class:`AccessTokenProviderBase` implementation. The + ``provider_factory`` callable determines which auth provider is instantiated + for each connection configuration, so new auth providers (MSAL, Entra ID + sidecar, future providers) can reuse the connection routing logic without + duplicating it. + """ + + _connections: dict[str, AccessTokenProviderBase] + _connections_map: _ConnectionMap + _config_map: dict[str, AgentAuthConfiguration | Configuration] + + @overload + def __init__( + self, + provider_factory: Callable[[Configuration], AccessTokenProviderBase], + *, + config: Configuration, + **kwargs, + ): ... + + @overload + def __init__( + self, + provider_factory: Callable[[AgentAuthConfiguration], AccessTokenProviderBase], + connections_configurations: dict[str, AgentAuthConfiguration] | None = None, + connections_map: list[dict[str, str]] | None = None, + *, + config: None = None, + **kwargs, + ): ... + + def __init__( + self, + provider_factory: Callable[..., AccessTokenProviderBase], + connections_configurations: dict[str, AgentAuthConfiguration] | None = None, + connections_map: list[dict[str, str]] | None = None, + *, + config: Configuration | None = None, + **kwargs, + ): + """ + Initialize the connection manager. + + :param provider_factory: Factory that creates an + :class:`microsoft_agents.hosting.core.AccessTokenProviderBase` from an + :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`. + :type provider_factory: Callable[[AgentAuthConfiguration], AccessTokenProviderBase] + :param connections_configurations: A dictionary of connection configurations. + :type connections_configurations: dict[str, :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`] + :param connections_map: A list of connection mappings. + :type connections_map: list[dict[str, str]] + :raises ValueError: If no service connection configuration is provided. + :param config: Application configuration. In this mode the factory receives + each connection's settings as Configuration. Legacy inputs continue to + pass AgentAuthConfiguration to the factory. + """ + + self._provider_factory = provider_factory + self._connections = {} + self._config_map = {} + if config is not None: + if connections_configurations is not None or connections_map is not None: + raise ValueError( + "Cannot provide both 'config' and legacy connection arguments." + ) + self._load_configuration(Configuration.from_mapping(config, **kwargs)) + else: + self._load_legacy_connections( + connections_configurations, connections_map, **kwargs + ) + + if not self._connections.get("SERVICE_CONNECTION"): + raise ValueError("No service connection configuration provided.") + _log_config(logger, self._config_map, self._connections_map) + + def _load_configuration(self, config: Configuration) -> None: + connection_config = config.get_section( + xpath("connections"), default=Configuration() + ) + for name in connection_config: + section = connection_config.get_section(name) + settings = section.get_section(xpath("settings"), default=section) + self._connections[name] = self._provider_factory(settings) + self._config_map[name] = settings + self._connections_map = _ConnectionMap(config.get(xpath("connections_map"))) + + def _load_legacy_connections( + self, + connections_configurations: dict[str, AgentAuthConfiguration] | None, + connections_map: list[dict[str, str]] | None, + **kwargs, + ) -> None: + raw_connections_map = ( + connections_map + if connections_map is not None + else kwargs.get("CONNECTIONSMAP", []) + ) + + self._connections_map = _ConnectionMap(raw_connections_map) + legacy_configs: dict[str, AgentAuthConfiguration] = {} + if connections_configurations: + for ( + connection_name, + agent_auth_config, + ) in connections_configurations.items(): + self._connections[connection_name] = self._provider_factory( + agent_auth_config + ) + legacy_configs[connection_name] = agent_auth_config + else: + raw_configurations: dict[str, dict] = kwargs.get("CONNECTIONS", {}) + for connection_name, connection_settings in raw_configurations.items(): + parsed_configuration = AgentAuthConfiguration( + **connection_settings.get("SETTINGS", {}) + ) + self._connections[connection_name] = self._provider_factory( + parsed_configuration + ) + legacy_configs[connection_name] = parsed_configuration + + # JWT-patch + for legacy_config in legacy_configs.values(): + legacy_config._connections = legacy_configs + self._config_map.update(legacy_configs) + + def get_connection(self, connection_name: str | None) -> AccessTokenProviderBase: + """ + Get the OAuth connection for the agent. + + :param connection_name: The name of the connection. + :type connection_name: str | None + :return: The OAuth connection for the agent. + :rtype: :class:`microsoft_agents.hosting.core.AccessTokenProviderBase` + """ + original_name = connection_name + connection_name = connection_name or "SERVICE_CONNECTION" + connection = self._connections.get(connection_name, None) + if not connection: + if original_name: + raise ValueError(f"No connection found for '{original_name}'.") + else: + raise ValueError( + "No default service connection found. Expected 'SERVICE_CONNECTION'." + ) + return connection + + def get_default_connection(self) -> AccessTokenProviderBase: + """ + Get the default OAuth connection for the agent. + + :return: The default OAuth connection for the agent. + :rtype: :class:`microsoft_agents.hosting.core.AccessTokenProviderBase` + """ + connection = self._connections.get("SERVICE_CONNECTION", None) + if not connection: + raise ValueError( + "No default service connection found. Expected 'SERVICE_CONNECTION'." + ) + return connection + + @staticmethod + def _service_url_matches(pattern: str, service_url: str) -> bool: + """Return whether a SERVICEURL map pattern matches the service URL. + + ``"*"``/empty match any URL. Otherwise the pattern is treated as a regex + and matched with an unanchored search, mirroring the .NET + ``ConfigurationConnections`` which uses ``Regex.Match``. + + :raises ValueError: If the pattern is not a valid regex. + """ + if pattern == "*" or pattern == "": + return True + try: + return re.search(pattern, service_url, re.IGNORECASE) is not None + except re.error as exc: + raise ValueError( + f"Invalid SERVICEURL regex '{pattern}' in connections map: {exc}" + ) from exc + + def get_token_provider( + self, claims_identity: ClaimsIdentity, service_url: str + ) -> AccessTokenProviderBase: + """ + Get the OAuth token provider for the agent. + + :param claims_identity: The claims identity of the bot. + :type claims_identity: :class:`microsoft_agents.hosting.core.ClaimsIdentity` + :param service_url: The service URL of the bot. + :type service_url: str + :return: The OAuth token provider for the agent. + :rtype: :class:`microsoft_agents.hosting.core.AccessTokenProviderBase` + :raises ValueError: If no connection is found for the given audience and service URL. + """ + if not claims_identity or not service_url: + raise ValueError( + "Claims identity and Service URL are required to get the token provider." + ) + + if not self._connections_map: + return self.get_default_connection() + + aud = claims_identity.get_app_id() or "" + for item in self._connections_map: + audience_match = True + item_aud = item.audience + if item_aud: + audience_match = item_aud.lower() == aud.lower() + + if audience_match and self._service_url_matches( + item.service_url, service_url + ): + return self.get_connection(item.connection) + + raise ValueError( + f"No connection found for audience '{aud}' and serviceUrl '{service_url}'." + ) + + def get_token_provider_from_activity( + self, + claims_identity: ClaimsIdentity, + activity: Activity, + ) -> AccessTokenProviderBase: + """ + Get the OAuth token provider for the agent from an activity. + + :param claims_identity: The claims identity of the agent. + :param activity: The activity from which to get the token provider. + :return: The access token provider for the agent. + """ + provider = self.get_token_provider(claims_identity, activity.service_url) + if ( + activity.is_agentic_request() + and provider.config.alternate_blueprint_connection_name + ): + provider = self.get_connection( + provider.config.alternate_blueprint_connection_name + ) + return provider + + def get_default_connection_configuration(self) -> AgentAuthConfiguration: + """ + Get the default connection configuration for the agent. + + :return: The default connection configuration for the agent. + :rtype: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` + """ + config = self._config_map.get("SERVICE_CONNECTION") + if isinstance(config, Configuration): + raise ValueError( + "No legacy AgentAuthConfiguration available. " + "Use get_default_connection().config instead." + ) + if not config: + raise ValueError( + "No default service connection configuration found. Expected 'SERVICE_CONNECTION'." + ) + return config diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_settings_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_settings_base.py new file mode 100644 index 000000000..b887f7d39 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_settings_base.py @@ -0,0 +1,197 @@ +# 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, 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 + + +def _read(configuration: Any, *names: str, default: Any = None) -> Any: + """Read the first populated attribute from ``configuration``. + + Tolerates both the SDK's upper-case configuration keys (e.g. ``CLIENT_ID``) + and the snake_case property aliases, so settings can be bound from an + :class:`AgentAuthConfiguration`, a raw mapping-like object, or another + settings instance. + """ + is_mapping = isinstance(configuration, Mapping) + for name in names: + value = ( + configuration.get(name) + if is_mapping + else getattr(configuration, name, None) + ) + if value is not None: + return value + return default + + +class _ConnectionSettingsBase: + """Common, provider-agnostic connection settings shared by all providers. + + Mirrors the .NET ``Microsoft.Agents.Authentication.ConnectionSettingsBase`` + abstract base. Provider-specific settings classes (for example the Entra + sidecar's :class:`SidecarConnectionSettings`) extend this with their own + fields, enabling an extensible, connection-type-based configuration model + that matches the .NET provider pattern. + + Instances are normally produced from an :class:`AgentAuthConfiguration` + (the raw, provider-agnostic configuration bag) via + :meth:`from_configuration`. Subclasses bind their own fields and merge the + common base fields via :meth:`base_kwargs_from_configuration`. + + Property names are the Python snake_case equivalents of the .NET + ``ConnectionSettingsBase`` properties: + + ==================================== ==================================== + .NET Python + ==================================== ==================================== + ``ClientId`` ``client_id`` + ``AuthorityEndpoint`` ``authority`` + ``TenantId`` ``tenant_id`` + ``Scopes`` ``scopes`` + ``AlternateBlueprintConnectionName`` ``alternate_blueprint_connection_name`` + ==================================== ==================================== + """ + + def __init__( + self, + *, + client_id: str | None = None, + authority: str | None = None, + tenant_id: str | None = None, + scopes: list[str] | None = None, + alternate_blueprint_connection_name: str | None = None, + ): + self.client_id = client_id + self.authority = authority + self.tenant_id = tenant_id + self.scopes = scopes + self.alternate_blueprint_connection_name = alternate_blueprint_connection_name + + @staticmethod + def base_kwargs_from_configuration( + configuration: "AgentAuthConfiguration", + ) -> dict: + """Bind the common base fields from an :class:`AgentAuthConfiguration`. + + Returns a kwargs dict suitable for passing to ``__init__``. Subclasses + call this and merge it with their own provider-specific kwargs so the + base fields stay defined in a single place. + """ + # Accept both the underscored configuration keys and the SDK's env-style + # keys (e.g. ``load_configuration_from_env`` yields ``CLIENTID`` / + # ``TENANTID`` without underscores) so binding from a raw ``SETTINGS`` + # mapping doesn't silently drop values. + return dict( + client_id=_read(configuration, "CLIENT_ID", "CLIENTID", "client_id"), + authority=_read( + configuration, "AUTHORITY", "AUTHORITYENDPOINT", "authority" + ), + tenant_id=_read(configuration, "TENANT_ID", "TENANTID", "tenant_id"), + scopes=_read(configuration, "SCOPES", "scopes"), + alternate_blueprint_connection_name=_read( + configuration, + "ALT_BLUEPRINT_ID", + "ALT_BLUEPRINT_NAME", + "ALTERNATEBLUEPRINTCONNECTIONNAME", + "alternate_blueprint_connection_name", + ), + ) + + @classmethod + def from_configuration( + cls, configuration: "AgentAuthConfiguration" + ) -> "_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.pop("_connections", None) + attrs.pop("_configured_issuers", None) + 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/connections.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connections.py similarity index 95% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connections.py index 7098660d4..cb1eeb458 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connections.py @@ -7,8 +7,8 @@ from microsoft_agents.activity import Activity from .agent_auth_configuration import AgentAuthConfiguration -from .access_token_provider_base import AccessTokenProviderBase -from .claims_identity import ClaimsIdentity +from ..access_token_provider_base import AccessTokenProviderBase +from ..claims_identity import ClaimsIdentity class Connections(Protocol): diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connections_map.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connections_map.py new file mode 100644 index 000000000..14750f4cc --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connections_map.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from collections.abc import Mapping +from dataclasses import dataclass + +from microsoft_agents.activity.config import Configuration, xpath + + +@dataclass(frozen=True) +class _ConnectionMapItem: + service_url: str = "" + audience: str = "" + connection: str = "" + + +class _ConnectionMap: + """Ordered connection routing rules bound from lists or named mappings.""" + + def __init__(self, items: list | Mapping | None = None): + if items is None: + items = [] + if not isinstance(items, (list, Mapping)): + raise TypeError("Connections map must be a list or mapping.") + values = items.values() if isinstance(items, Mapping) else items + self.items: list[_ConnectionMapItem] = [] + for index, value in enumerate(values): + if not isinstance(value, Mapping): + raise TypeError(f"Connections map entry {index} must be a mapping.") + config = Configuration(value) + self.items.append( + _ConnectionMapItem( + service_url=config.get_str(xpath("service_url"), default=""), + audience=config.get_str(xpath("audience"), default=""), + connection=config.get_str(xpath("connection"), default=""), + ) + ) + + def __iter__(self): + return iter(self.items) + + def __bool__(self): + return bool(self.items) 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 fa512f91d..9569ecaad 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 @@ -8,7 +8,7 @@ from microsoft_agents.activity.config import Configuration, xpath from .._entra_issuers import default_connection_issuers -from ..connection_settings_base import ConnectionSettingsBase +from ..connections import ConnectionSettingsBase @dataclass(frozen=True) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py index 4a8189c3f..d75cb34a0 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/rest_channel_service_client_factory.py @@ -49,8 +49,7 @@ async def _get_agentic_token(self, context: TurnContext, service_url: str) -> st context.identity, service_url ) - configuration = connection.configuration - alt_blueprint_id = configuration.ALT_BLUEPRINT_ID + alt_blueprint_id = connection.config.alternate_blueprint_connection_name if alt_blueprint_id: logger.debug( diff --git a/libraries/microsoft-agents-hosting-core/readme.md b/libraries/microsoft-agents-hosting-core/readme.md index 5b589dcdb..02d02b523 100644 --- a/libraries/microsoft-agents-hosting-core/readme.md +++ b/libraries/microsoft-agents-hosting-core/readme.md @@ -187,6 +187,24 @@ Additionally we provide a Copilot Studio Client, to interact with Agents created | `microsoft-agents-copilotstudio-client` | [![PyPI](https://img.shields.io/pypi/v/microsoft-agents-copilotstudio-client)](https://pypi.org/project/microsoft-agents-copilotstudio-client/) | Direct to Engine client to interact with Agents created in CopilotStudio | +## Connection Configuration + +`microsoft_agents.hosting.core.authorization.connections` contains `Connections`, +`ConnectionManager`, `ConnectionSettingsBase`, and the legacy `AgentAuthConfiguration`. +Existing top-level and legacy module imports remain supported. + +Pass application settings as `ConnectionManager(provider_factory, config=configuration)` +to deliver each connection's settings to the provider as a `Configuration`. Read bound +provider settings through `provider.config`; provider-specific settings derive from +`ConnectionSettingsBase`. Connection maps accept ordered lists or named mappings with +`service_url`, `audience`, and `connection` fields, including environment-style aliases. + +Existing `connections_configurations`, `connections_map`, and `**legacy_config` +constructor calls still pass `AgentAuthConfiguration` objects to the provider factory. +`get_default_connection_configuration()` remains available for those legacy calls; +configuration-based managers should use `get_default_connection().config`. +The environment loader is unchanged. + ## Installation ```bash diff --git a/tests/authentication_entra_auth_sidecar/test_settings.py b/tests/authentication_entra_auth_sidecar/test_settings.py index 98708c9e3..6e4ddd666 100644 --- a/tests/authentication_entra_auth_sidecar/test_settings.py +++ b/tests/authentication_entra_auth_sidecar/test_settings.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core import AgentAuthConfiguration, ConnectionManager from microsoft_agents.authentication.entra_auth_sidecar import SidecarAuth from microsoft_agents.authentication.entra_auth_sidecar._models import ( @@ -40,6 +41,35 @@ def test_empty_collapses_to_none(self): class TestSettingsFromConfiguration: + def test_configuration_manager_binds_typed_sidecar_settings(self): + manager = ConnectionManager( + SidecarAuth, + config=Configuration( + connections={ + "SERVICE_CONNECTION": { + "settings": { + "client_id": "blueprint", + "service_name": " service ", + "blueprint_service_name": " ", + "sidecar_base_url": "http://localhost:5178", + "retry_count": "5", + "request_timeout": "45", + "bypass_local_network_restriction": "false", + "scopes": "scope1, scope2", + } + } + }, + ), + ) + settings = manager.get_default_connection().config + assert isinstance(settings, SidecarConnectionSettings) + assert settings.service_name == "service" + assert settings.blueprint_service_name == "agenticblueprint" + assert settings.retry_count == 5 + assert settings.request_timeout == 45.0 + assert settings.bypass_local_network_restriction is False + assert settings.scopes == ["scope1", "scope2"] + def test_reads_sidecar_settings_from_upper_case_kwargs(self): config = AgentAuthConfiguration( auth_type="EntraAuthSideCar", @@ -62,11 +92,11 @@ def test_defaults_when_absent(self): assert settings.blueprint_service_name == "agenticblueprint" def test_inherits_connection_settings_base(self): - from microsoft_agents.hosting.core.authorization.connection_settings_base import ( # noqa: E501 - _ConnectionSettingsBase, + from microsoft_agents.hosting.core.authorization.connections import ( + ConnectionSettingsBase, ) - assert issubclass(SidecarConnectionSettings, _ConnectionSettingsBase) + assert issubclass(SidecarConnectionSettings, ConnectionSettingsBase) def test_binds_base_fields_from_configuration(self): config = AgentAuthConfiguration( @@ -176,6 +206,22 @@ def test_unrecognized_or_unset_bypass_value_fails_safe_to_false(self, raw): settings = SidecarConnectionSettings(bypass_local_network_restriction=raw) assert settings.bypass_local_network_restriction is False + @pytest.mark.parametrize("raw", ["maybe", "no", "off", "yes", "on", ""]) + @pytest.mark.parametrize("config_type", [AgentAuthConfiguration, Configuration]) + def test_unrecognized_bypass_value_from_configuration_keeps_protection( + self, raw, config_type + ): + config = config_type( + auth_type="EntraAuthSideCar", + client_id="blueprint-id", + SIDECAR_BASE_URL="http://example.com", + BYPASS_LOCAL_NETWORK_RESTRICTION=raw, + ) + settings = SidecarConnectionSettings.from_configuration(config) + assert settings.bypass_local_network_restriction is False + with pytest.raises(SidecarAuthError): + SidecarAuth(config) + def test_retry_count_string_coerced_to_int(self): settings = SidecarConnectionSettings(retry_count="5") assert settings.retry_count == 5 diff --git a/tests/authentication_entra_auth_sidecar/test_sidecar_auth.py b/tests/authentication_entra_auth_sidecar/test_sidecar_auth.py index f84743401..72ea2f76d 100644 --- a/tests/authentication_entra_auth_sidecar/test_sidecar_auth.py +++ b/tests/authentication_entra_auth_sidecar/test_sidecar_auth.py @@ -7,7 +7,9 @@ from datetime import datetime, timedelta, timezone from microsoft_agents.hosting.core import AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration from microsoft_agents.authentication.entra_auth_sidecar import SidecarAuth +from microsoft_agents.authentication.entra_auth_sidecar import SidecarConnectionSettings from microsoft_agents.authentication.entra_auth_sidecar._models import ( SidecarTokenResult, ) @@ -51,6 +53,35 @@ def _make_auth(token="token", scopes=None, healthy=True, **settings): return SidecarAuth(config, sidecar_client=client), client +@pytest.mark.asyncio +@pytest.mark.parametrize("bound", [False, True]) +async def test_typed_settings_preserve_agentic_scopes_and_reuse_token_credential_provider( + bound, +): + config = Configuration( + settings={ + "CLIENTID": "blueprint", + "SERVICE_NAME": "service", + "BLUEPRINT_SERVICE_NAME": "blueprint-service", + "SCOPES": "scope1, scope2", + } + ) + settings = SidecarConnectionSettings.from_configuration(config) + auth = SidecarAuth( + settings if bound else config, sidecar_client=FakeSidecarClient() + ) + await auth.get_agentic_instance_token("tenant", "instance") + service, options = auth._sidecar_client.calls[0] + assert service == "service" + assert options.scopes == ["scope1", "scope2"] + assert auth.config.client_id == "blueprint" + credential = auth.get_token_credential() + assert credential._provider is auth + assert credential._config is auth.config + with pytest.raises(ValueError, match="Use 'config'"): + _ = auth.configuration + + class TestSidecarAuthMapping: @pytest.mark.asyncio async def test_get_access_token_uses_service_name_app_token(self): diff --git a/tests/authentication_msal/test_connection_settings.py b/tests/authentication_msal/test_connection_settings.py new file mode 100644 index 000000000..84ca22ad1 --- /dev/null +++ b/tests/authentication_msal/test_connection_settings.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity.config import Configuration +from microsoft_agents.authentication.msal import ( + MsalAuth, + MsalConnectionManager, + MsalConnectionSettings, +) +from microsoft_agents.hosting.core import ( + AgentAuthConfiguration, + AuthTypes, + ClaimsIdentity, +) + + +@pytest.mark.parametrize( + "auth_type, field", + [ + (AuthTypes.certificate, "CERT_PFX_FILE"), + (AuthTypes.federated_credentials, "FEDERATED_CLIENT_ID"), + (AuthTypes.workload_identity, "FEDERATED_TOKEN_FILE"), + ], +) +def test_required_credentials_are_validated(auth_type, field): + with pytest.raises(ValueError, match=field): + MsalConnectionSettings.from_mapping(auth_type=auth_type) + + +def test_msal_settings_bind_aliases_and_round_trip_legacy_fields_without_mutation(): + legacy = AgentAuthConfiguration( + client_id="client", + tenant_id="tenant", + client_secret="secret", + authority="https://login.microsoftonline.com", + scopes=["scope"], + ALT_BLUEPRINT_NAME="blueprint", + REGIONALAUTHORITY="westus", + custom_option="value", + ) + settings = MsalConnectionSettings._from_agent_auth_config(legacy) + before = dict(settings.extras) + restored = settings._to_agent_auth_config() + assert settings.client_id == restored.CLIENT_ID == "client" + assert settings.client_secret == restored.CLIENT_SECRET == "secret" + assert settings.azure_region == restored.AZURE_REGION == "westus" + assert ( + settings.alternate_blueprint_connection_name + == restored.ALT_BLUEPRINT_ID + == "blueprint" + ) + assert restored.provider_settings["custom_option"] == "value" + assert settings.extras == before + assert "_connections" not in settings.extras + + +@pytest.mark.parametrize("bound", [False, True]) +def test_provider_accepts_configuration_or_bound_settings_and_builds_certificate_client( + mocker, bound +): + settings = MsalConnectionSettings.from_mapping( + { + "SETTINGS": { + "CLIENTID": "client", + "TENANTID": "tenant", + "AUTHTYPE": AuthTypes.certificate, + "CERTPFXFILE": "certificate.pfx", + "SENDX5C": "true", + "REGIONALAUTHORITY": "westus", + } + } + ) + config = ( + settings + if bound + else Configuration( + settings={ + "client_id": "client", + "tenant_id": "tenant", + "auth_type": AuthTypes.certificate, + "cert_pfx_file": "certificate.pfx", + "send_x5c": "true", + "azure_region": "westus", + } + ) + ) + factory = mocker.patch( + "microsoft_agents.authentication.msal.msal_auth.ConfidentialClientApplication" + ) + auth = MsalAuth(config) + auth._create_client_application() + assert auth.config.client_id == "client" + assert factory.call_args.kwargs["client_credential"] == { + "private_key_pfx_path": "certificate.pfx", + "public_certificate": True, + } + assert factory.call_args.kwargs["azure_region"] == "westus" + assert auth.configuration.CLIENT_ID == "client" + assert auth.get_token_credential()._provider is auth + + +def test_msal_connection_manager_configuration_routes_and_exposes_typed_settings(): + manager = MsalConnectionManager( + config=Configuration( + connections={ + "SERVICE_CONNECTION": {"settings": {"client_id": "service"}}, + "MCS": {"settings": {"client_id": "mcs", "tenant_id": "tenant"}}, + }, + connections_map=[{"audience": "mcs", "connection": "MCS"}], + ) + ) + provider = manager.get_token_provider( + ClaimsIdentity({"aud": "MCS"}), "https://example.com" + ) + assert provider is manager.get_connection("MCS") + assert isinstance(provider.config, MsalConnectionSettings) + assert provider.config.client_id == "mcs" diff --git a/tests/authentication_msal/test_msal_connection_manager.py b/tests/authentication_msal/test_msal_connection_manager.py index 23bbc023d..d0bcbb392 100644 --- a/tests/authentication_msal/test_msal_connection_manager.py +++ b/tests/authentication_msal/test_msal_connection_manager.py @@ -35,7 +35,7 @@ def test_init_from_config(self): config = load_configuration_from_env(mock_environ) connection_manager = MsalConnectionManager(**config) for key in connection_manager._connections: - auth = connection_manager.get_connection(key)._msal_configuration + auth = connection_manager.get_connection(key).configuration assert auth.AUTH_TYPE == AuthTypes.client_secret assert auth.CLIENT_ID == f"test-client-id-{key}" assert auth.TENANT_ID == f"test-tenant-id-{key}" diff --git a/tests/hosting_core/authorization/test_connections_map.py b/tests/hosting_core/authorization/test_connections_map.py new file mode 100644 index 000000000..773968cc1 --- /dev/null +++ b/tests/hosting_core/authorization/test_connections_map.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.hosting.core.authorization.connections.connections_map import ( + _ConnectionMap, +) + + +@pytest.mark.parametrize("items", [None, [], {}]) +def test_empty_maps_disable_routing(items): + assert not _ConnectionMap(items) + + +@pytest.mark.parametrize("items", ["invalid", [None], {"rule": "invalid"}]) +def test_invalid_map_shapes_raise(items): + with pytest.raises(TypeError, match="mapping"): + _ConnectionMap(items) + + +def test_map_preserves_order_and_binds_environment_aliases(): + rules = _ConnectionMap( + { + "first": { + "SERVICEURL": "example", + "AUDIENCE": "client", + "CONNECTION": "OTHER", + }, + "second": {"connection": "SERVICE_CONNECTION"}, + } + ) + first, second = rules + assert (first.service_url, first.audience, first.connection) == ( + "example", + "client", + "OTHER", + ) + assert (second.service_url, second.audience, second.connection) == ( + "", + "", + "SERVICE_CONNECTION", + ) diff --git a/tests/hosting_core/test_connection_manager.py b/tests/hosting_core/test_connection_manager.py index f676a09c1..600efccd9 100644 --- a/tests/hosting_core/test_connection_manager.py +++ b/tests/hosting_core/test_connection_manager.py @@ -6,11 +6,13 @@ from azure.core.credentials_async import AsyncTokenCredential from microsoft_agents.activity import Activity, ChannelAccount, RoleTypes +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core import ( AgentAuthConfiguration, AccessTokenProviderBase, ClaimsIdentity, ConnectionManager, + ConnectionSettingsBase, ) from microsoft_agents.hosting.core.authorization._helpers import ( _CallableTokenCredential, @@ -39,6 +41,105 @@ async def get_token(*scopes: str, **kwargs) -> AccessToken: return _CallableTokenCredential(get_token) +class ConfiguredProvider(FakeProvider): + def __init__(self, configuration: Configuration): + self.received_config = configuration + self._settings = ConnectionSettingsBase.from_configuration(configuration) + + @property + def config(self) -> ConnectionSettingsBase: + return self._settings + + @property + def configuration(self) -> AgentAuthConfiguration: + raise AssertionError( + "Configuration-based routing must not require legacy settings" + ) + + +@pytest.mark.parametrize("named_map", [False, True]) +def test_configuration_manager_binds_aliases_and_routes_without_legacy_settings( + named_map, +): + rules = [ + {"audience": "client", "service_url": "example", "connection": "OTHER"}, + {"service_url": "*", "connection": "SERVICE_CONNECTION"}, + ] + config = Configuration( + connections={ + "SERVICE_CONNECTION": {"settings": {"client_id": "default"}}, + "OTHER": {"settings": {"client_id": "other"}}, + }, + connections_map={"first": rules[0], "second": rules[1]} if named_map else rules, + ) + manager = ConnectionManager(ConfiguredProvider, config=config) + provider = manager.get_token_provider( + ClaimsIdentity({"aud": "CLIENT"}), "https://example.com" + ) + assert provider is manager.get_connection("OTHER") + assert provider.config.client_id == "other" + assert provider.received_config._base_path == "connections::OTHER::settings" + assert ( + manager.get_token_provider( + ClaimsIdentity({"aud": "unmatched"}), "https://example.com" + ) + is manager.get_default_connection() + ) + with pytest.raises(ValueError, match="get_default_connection\\(\\).config"): + manager.get_default_connection_configuration() + + +def test_configuration_manager_redirects_agentic_requests_to_alternate_blueprint(): + manager = ConnectionManager( + ConfiguredProvider, config=Configuration(ALT_BLUEPRINT_CONFIG) + ) + activity = Activity( + type="message", + service_url="https://example.com", + recipient=ChannelAccount(id="instance", role=RoleTypes.agentic_identity), + ) + assert manager.get_token_provider_from_activity( + ClaimsIdentity({}), activity + ) is manager.get_connection("ALT_BLUEPRINT") + + +@pytest.mark.parametrize( + "legacy", [{"connections_configurations": {}}, {"connections_map": []}] +) +def test_configuration_manager_rejects_mixed_constructor_modes(legacy): + with pytest.raises(ValueError, match="both 'config'"): + ConnectionManager(ConfiguredProvider, config=Configuration(), **legacy) + + +@pytest.mark.parametrize( + "config, path", + [ + ({"connections": []}, "connections"), + ( + {"connections": {"SERVICE_CONNECTION": "invalid"}}, + "connections::SERVICE_CONNECTION", + ), + ( + {"connections": {"SERVICE_CONNECTION": {"settings": []}}}, + "connections::SERVICE_CONNECTION::settings", + ), + ], +) +def test_configuration_manager_reports_invalid_connection_sections(config, path): + with pytest.raises(TypeError, match=path): + ConnectionManager(ConfiguredProvider, config=Configuration(config)) + + +def test_legacy_provider_config_fallback_preserves_base_fields(): + legacy = AgentAuthConfiguration( + client_id="client", ALT_BLUEPRINT_NAME="blueprint", scopes=["scope"] + ) + settings = FakeProvider(legacy).config + assert settings.client_id == "client" + assert settings.alternate_blueprint_connection_name == "blueprint" + assert settings.scopes == ["scope"] + + @pytest.mark.asyncio async def test_fake_provider_get_token_credential_is_synchronous(): provider = FakeProvider(AgentAuthConfiguration()) @@ -250,11 +351,10 @@ def test_default_connection_configuration(self): config = cm.get_default_connection_configuration() assert config.CLIENT_ID == "client-service" - def test_default_connections_map_is_empty_list(self): + def test_default_connections_map_is_empty(self): config = {k: v for k, v in ENV_CONFIG.items() if k != "CONNECTIONSMAP"} cm = self._make(**config) - assert cm._connections_map == [] - assert isinstance(cm._connections_map, list) + assert not cm._connections_map def test_explicit_empty_connections_map_overrides_kwargs(self): # An explicit empty map must win over a CONNECTIONSMAP kwarg and disable @@ -264,7 +364,7 @@ def test_explicit_empty_connections_map_overrides_kwargs(self): connections_map=[], **ENV_CONFIG, ) - assert cm._connections_map == [] + assert not cm._connections_map claims = ClaimsIdentity(claims={"aud": "api://service"}) assert ( cm.get_token_provider(claims, "https://service.com/api") diff --git a/tests/hosting_core/test_log_config.py b/tests/hosting_core/test_log_config.py index 669945c92..ed5f52054 100644 --- a/tests/hosting_core/test_log_config.py +++ b/tests/hosting_core/test_log_config.py @@ -4,6 +4,7 @@ import pytest from microsoft_agents.hosting.core import AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core.authorization._log_config import ( _log_config, _redact_scopes, @@ -73,6 +74,27 @@ def test_redact_url_or_none_redacts_value(self): class TestLogConfig: + def test_configuration_binding_redacts_credentials_and_provider_extras(self): + summary = _summarize_auth_configs( + { + "SERVICE_CONNECTION": Configuration( + { + "client_id": "client-id-secret", + "client_secret": "synthetic-secret", + "authorityendpoint": "https://login.microsoftonline.com/tenant", + "scopes": "scope1 scope2", + "custom_secret": "provider-secret", + } + ) + } + ) + parsed = json.loads(summary)[0] + assert parsed["CLIENTSECRET"] == "..." + assert parsed["AUTHORITY"] == "https://login.microsoftonline.com/..." + assert parsed["SCOPES"] == "... [2 scope(s)]" + assert "synthetic-secret" not in summary + assert "provider-secret" not in summary + def test_summarize_auth_configs_formats_connections_as_json_array(self): summary = _summarize_auth_configs( { diff --git a/tests/hosting_core/test_rest_channel_service_client_factory.py b/tests/hosting_core/test_rest_channel_service_client_factory.py index 0f387cdbf..078800854 100644 --- a/tests/hosting_core/test_rest_channel_service_client_factory.py +++ b/tests/hosting_core/test_rest_channel_service_client_factory.py @@ -14,6 +14,7 @@ Connections, AccessTokenProviderBase, AgentAuthConfiguration, + ConnectionSettingsBase, ) from microsoft_agents.hosting.core.connector.teams import TeamsConnectorClient @@ -294,7 +295,9 @@ async def test_create_connector_client_agentic_identity( if alt_blueprint: auth_config.ALT_BLUEPRINT_ID = "alt_blueprint_id" connection_manager.get_connection = mocker.Mock(return_value=token_provider) - token_provider.configuration = auth_config + token_provider.config = ConnectionSettingsBase._from_agent_auth_config( + auth_config + ) factory = RestChannelServiceClientFactory(connection_manager) @@ -338,7 +341,7 @@ async def test_create_connector_client_agentic_identity_non_msal_provider( self, mocker, activity_agentic_identity ): """Providers without ``_msal_configuration`` (e.g. the Entra sidecar) - must still work via the generic ``configuration`` attribute.""" + must still work via the generic ``config`` attribute.""" # setup mock_connector_client = mocker.Mock(spec=TeamsConnectorClient) mocker.patch.object( @@ -355,10 +358,12 @@ async def test_create_connector_client_agentic_identity_non_msal_provider( connection_manager = mocker.Mock(spec=Connections) connection_manager.get_token_provider = mocker.Mock(return_value=token_provider) - # Non-MSAL provider: exposes ``configuration`` with an alternate blueprint. + # Non-MSAL provider: exposes typed settings with an alternate blueprint. auth_config = AgentAuthConfiguration() auth_config.ALT_BLUEPRINT_ID = "alt_blueprint_id" - token_provider.configuration = auth_config + token_provider.config = ConnectionSettingsBase._from_agent_auth_config( + auth_config + ) connection_manager.get_connection = mocker.Mock(return_value=token_provider) factory = RestChannelServiceClientFactory(connection_manager) @@ -375,7 +380,7 @@ async def test_create_connector_client_agentic_identity_non_msal_provider( ["scope1", "scope2"], ) - # verify the alternate blueprint redirect happened via ``configuration`` + # Verify the alternate blueprint redirect uses provider-independent settings. connection_manager.get_connection.assert_called_once_with("alt_blueprint_id") token_provider.get_agentic_instance_token.assert_called_once_with( "tenant_id", "agentic_app_id" @@ -399,7 +404,7 @@ async def test_create_connector_client_agentic_no_configuration( token_provider.get_agentic_instance_token = mocker.AsyncMock( return_value=(DEFAULTS.token, DEFAULTS.token) ) - token_provider.configuration = AgentAuthConfiguration() + token_provider.config = ConnectionSettingsBase() connection_manager = mocker.Mock(spec=Connections) connection_manager.get_token_provider = mocker.Mock(return_value=token_provider) @@ -450,7 +455,9 @@ async def test_create_connector_client_agentic_user( if alt_blueprint: auth_config.ALT_BLUEPRINT_ID = "alt_blueprint_id" connection_manager.get_connection = mocker.Mock(return_value=token_provider) - token_provider.configuration = auth_config + token_provider.config = ConnectionSettingsBase._from_agent_auth_config( + auth_config + ) factory = RestChannelServiceClientFactory(connection_manager) From f1b28e1627c0483fb8c473c26f29691c0b020c2e Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Thu, 29 Oct 2026 21:37:39 -0700 Subject: [PATCH 10/15] Updating changelog --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index a394a6340..a95fa9c12 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,8 @@ - **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). +- **Connection Configuration**: Added `Configuration`-based initialization to `ConnectionManager` and `MsalConnectionManager`, with typed audience/service-URL routing maps supporting lists and named mappings. Consolidated shared connection APIs under `authorization.connections` while retaining legacy import paths and constructor inputs. +- **Typed Authentication Provider Settings**: Added `MsalConnectionSettings` and migrated `SidecarConnectionSettings` to derive from `ConnectionSettingsBase`. MSAL and Entra sidecar providers now accept bound settings or `Configuration` and expose typed settings through `.config`, while retaining legacy `AgentAuthConfiguration` inputs and the existing environment-loader contract. --- From 704de2896c8b0776da297bde5b6b655c1ee15287 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 4 Nov 2026 10:54:05 -0800 Subject: [PATCH 11/15] Addressing PR feedback and updating configuration handling in test sampels --- .../activity/config/configuration.py | 16 ++++ .../entra_auth_sidecar/connection_settings.py | 40 ++------ .../entra_auth_sidecar/sidecar_auth.py | 4 +- .../microsoft_agents/hosting/core/__init__.py | 23 +++-- .../hosting/core/app/oauth/auth_handler.py | 7 +- .../hosting/core/authorization/__init__.py | 10 +- .../hosting/core/authorization/_log_config.py | 8 +- .../connections/agent_auth_configuration.py | 8 +- .../connections/connection_manager.py | 73 +++++++------- .../connections/connection_settings_base.py | 11 +-- .../jwt/token_validation_configuration.py | 2 +- pytest.ini | 6 ++ test_samples/agentic-test/src/agent.py | 6 +- test_samples/agentic-test/src/main.py | 4 +- test_samples/agentic-test/src/start_server.py | 10 +- test_samples/app_style/authorization_agent.py | 8 +- .../app_style/echo_proactive_agent.py | 14 +-- test_samples/app_style/empty_agent.py | 8 +- test_samples/app_style/mcs_agent.py | 56 ++++------- test_samples/app_style/shared/start_server.py | 10 +- test_samples/app_style/streaming_agent.py | 8 +- test_samples/cards/agent.py | 9 +- test_samples/cards/start_server.py | 11 +-- .../compat/dialogs/complex_dialog/src/main.py | 4 +- .../compat/dialogs/custom_dialogs/src/main.py | 4 +- .../compat/dialogs/multi_turn/src/main.py | 4 +- .../compat/dialogs/oauth_prompt/src/main.py | 6 +- .../compat/dialogs/user_auth/src/main.py | 6 +- test_samples/compat/teams_agent/app.py | 6 +- .../copilot_studio_client_sample/app.py | 13 ++- .../copilot_studio_client_sample/config.py | 50 ++++------ test_samples/copilot_studio_connector/app.py | 13 +-- test_samples/entra_sidecar/agent.py | 19 ++-- .../extension-starter/src/sample/app.py | 6 +- .../extension-starter/src/sample/main.py | 4 +- .../src/sample/start_server.py | 10 +- .../extensions/slack-agent/src/app.py | 5 +- .../extensions/slack-agent/src/main.py | 4 +- .../slack-agent/src/start_server.py | 11 +-- test_samples/fastapi/authorization_agent.py | 12 +-- test_samples/fastapi/empty_agent.py | 12 +-- .../handling_attachments/src/agent.py | 5 +- test_samples/handling_attachments/src/main.py | 10 +- .../conversation-agent/src/agent.py | 5 +- .../conversation-agent/src/main.py | 4 +- .../conversation-agent/src/start_server.py | 9 +- .../graph-clients/src/agent.py | 5 +- .../hosting_msteams/graph-clients/src/main.py | 4 +- .../graph-clients/src/start_server.py | 9 +- .../message-extensions/src/agent.py | 9 +- .../message-extensions/src/main.py | 4 +- .../message-extensions/src/start_server.py | 8 +- .../hosting_msteams/task-modules/src/agent.py | 7 +- .../hosting_msteams/task-modules/src/main.py | 4 +- .../task-modules/src/start_server.py | 9 +- test_samples/otel/quickstart/src/agent.py | 6 +- test_samples/otel/quickstart/src/main.py | 4 +- .../otel/quickstart/src/start_server.py | 10 +- test_samples/otel/zero-code/src/agent.py | 6 +- test_samples/otel/zero-code/src/main.py | 4 +- .../otel/zero-code/src/start_server.py | 10 +- test_samples/proactive/proactive_agent.py | 10 +- .../teams_extension/meeting_events_agent.py | 7 +- .../message_extensions_agent.py | 7 +- .../teams_extension/shared/start_server.py | 12 +-- .../teams_extension/task_modules_agent.py | 9 +- test_samples/testing/src/app.py | 5 +- test_samples/testing/src/start_server.py | 11 +-- test_samples/tethered-agent/src/agent.py | 6 +- test_samples/tethered-agent/src/main.py | 4 +- .../tethered-agent/src/start_server.py | 10 +- tests/activity/config/test_configuration.py | 16 ++++ .../config/test_load_configuration.py | 34 +++++-- .../test_settings.py | 93 +++++++++--------- .../test_connection_settings.py | 95 ++++++++++++++++++- tests/authentication_msal/test_msal_auth.py | 14 +-- .../test_console_sample.py | 29 ++++-- .../app/_oauth/test_auth_handler.py | 24 +++++ .../authorization/test_connections_map.py | 2 +- .../test_jwt_connection_settings.py | 24 ++++- .../test_token_validation_configuration.py | 19 +--- tests/hosting_core/test_connection_manager.py | 91 +++++++++++++++++- .../test_connection_settings_base.py | 28 +++--- tests/hosting_core/test_log_config.py | 31 +++--- .../test_jwt_authorization_middleware.py | 12 ++- 85 files changed, 739 insertions(+), 527 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 ae6fd066f..e1ea6e1cc 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/config/configuration.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/config/configuration.py @@ -61,6 +61,21 @@ def __init__( raise ValueError("Cannot provide both '_original' and keyword arguments.") super().__init__(_original or kwargs) self._base_path = _base_path or "" + self._key = None + + @property + def base_path(self) -> str: + return self._base_path + + @property + def key(self) -> str: + if not self._key: + self._key = ( + self._base_path.rsplit(_PATH_SEPARATOR, maxsplit=1)[-1] + if self._base_path + else "" + ) + return self._key @classmethod def from_mapping( @@ -407,6 +422,7 @@ def format_scopes(scopes: str | list[str] | None) -> list[str]: if scopes is None: return [] if isinstance(scopes, str): + scopes = scopes.replace(",", " ") lst = scopes.strip().split() return [s for s in lst if s] return scopes diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py index f4f347e67..382291711 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py @@ -3,16 +3,16 @@ from __future__ import annotations -import warnings - from collections.abc import Mapping from dataclasses import dataclass +from typing_extensions import Self from microsoft_agents.activity.config import ( Configuration, xpath, ) from microsoft_agents.hosting.core.authorization.connections import ( + AgentAuthConfiguration, ConnectionSettingsBase, ) from .constants import ( @@ -85,8 +85,6 @@ def _get_init_kwargs(cls, config: Configuration) -> dict: default=DEFAULT_RETRY_COUNT, ) - base["scopes"] = cls._normalize_scopes(base.get("scopes", [])) - base.update( { "service_name": service_name, @@ -99,30 +97,12 @@ def _get_init_kwargs(cls, config: Configuration) -> dict: ) return base - @staticmethod - def _normalize_scopes(scopes) -> list[str] | None: - """Normalize scopes to a list of strings. - - Accepts a list, a string (space/comma-delimited), or a dict (produced when - scopes are supplied via env as ``SCOPES__0=...`` and parsed into - ``{"0": "..."}``). Returns ``None`` when no scopes are present. - """ - if scopes is None: - return None - if isinstance(scopes, str): - parts = [s.strip() for s in scopes.replace(",", " ").split()] - return [s for s in parts if s] or None - if isinstance(scopes, dict): - scopes = scopes.values() - result = [str(s).strip() for s in scopes if s and str(s).strip()] - return result or None - - # for backward compatibility with previous versions that used `from_configuration` @classmethod - def from_configuration(cls, configuration: Mapping) -> SidecarConnectionSettings: - warnings.warn( - "`from_configuration` is deprecated, use `from_mapping` instead.", - DeprecationWarning, - stacklevel=2, - ) - return cls.from_mapping(configuration) + def from_configuration( + cls, config: Configuration | Mapping | AgentAuthConfiguration | Self + ) -> Self: + if isinstance(config, AgentAuthConfiguration): + return cls._from_agent_auth_config(config) + if isinstance(config, Mapping): + config = Configuration.from_mapping(config) + return super().from_configuration(config) diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py index dd1edb6bd..ef79a6aae 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/sidecar_auth.py @@ -65,8 +65,8 @@ def __init__( if isinstance(configuration, AgentAuthConfiguration): self._configuration = configuration - self._settings = SidecarConnectionSettings.from_configuration( - SidecarConnectionSettings._from_agent_auth_config(configuration) + self._settings = SidecarConnectionSettings._from_agent_auth_config( + configuration ) else: self._configuration = None 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 c54857a63..310487196 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 @@ -53,16 +53,18 @@ from .app.state.turn_state import TurnState # Authorization -from .authorization.access_token_provider_base import AccessTokenProviderBase -from .authorization.authentication_constants import AuthenticationConstants -from .authorization.anonymous_token_provider import AnonymousTokenProvider -from .authorization.connections import Connections -from .authorization.connection_manager import ConnectionManager -from .authorization.agent_auth_configuration import AgentAuthConfiguration -from .authorization.claims_identity import ClaimsIdentity -from .authorization.jwt.jwt_token_validator import JwtTokenValidator -from .authorization.auth_types import AuthTypes -from .authorization.connection_settings_base import ConnectionSettingsBase +from .authorization import ( + AccessTokenProviderBase, + AuthenticationConstants, + AnonymousTokenProvider, + Connections, + ConnectionManager, + AgentAuthConfiguration, + ClaimsIdentity, + JwtTokenValidator, + AuthTypes, + ConnectionSettingsBase, +) # Client API from .client.agent_conversation_reference import AgentConversationReference @@ -118,6 +120,7 @@ "ConnectionSettingsBase", "ActivityHandler", "Agent", + "AuthTypes", "CardFactory", "ChannelAdapter", "ChannelApiHandlerProtocol", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py index 4d11b2128..619c36249 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/auth_handler.py @@ -71,8 +71,11 @@ def __init__( self._alt_blueprint_name = kwargs.get("ALT_BLUEPRINT_NAME", None) @staticmethod - def _format_scopes(scopes: str) -> list[str]: - lst = scopes.strip().split(" ") + def _format_scopes(scopes: str | list[str]) -> list[str]: + if isinstance(scopes, str): + lst = scopes.strip().split(" ") + else: + lst = scopes return [s for s in lst if s] @staticmethod 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 d88c1890b..fa9a5677a 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 @@ -4,13 +4,15 @@ from .access_token_provider_base import AccessTokenProviderBase from .authentication_constants import AuthenticationConstants from .anonymous_token_provider import AnonymousTokenProvider -from .connections import Connections -from .connection_manager import ConnectionManager -from .agent_auth_configuration import AgentAuthConfiguration +from .connections import ( + AgentAuthConfiguration, + Connections, + ConnectionManager, + ConnectionSettingsBase, +) from .claims_identity import ClaimsIdentity from .jwt import JwtTokenValidator from .auth_types import AuthTypes -from .connection_settings_base import ConnectionSettingsBase __all__ = [ "AccessTokenProviderBase", diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_log_config.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_log_config.py index 6a35fa112..ed16e3a8a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_log_config.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_log_config.py @@ -9,7 +9,7 @@ from microsoft_agents.activity.config import Configuration, xpath from microsoft_agents.activity._model_utils import pick_model_dict, SkipNone -from .connections import _ConnectionMap +from .connections.connections_map import _ConnectionMap _REDACTION_PEEK_LENGTH = 3 _REDACTION_THRESH = _REDACTION_PEEK_LENGTH + 6 @@ -114,7 +114,11 @@ def _summarize_auth_configs(config_map: dict[str, Configuration]) -> str: _redact_str_or_none(get_strn("TENANT_ID"), peek=True) ), CLIENTSECRET=SkipNone(_redact_str_or_none(get_strn("CLIENT_SECRET"))), - AUTHORITY=SkipNone(_redact_url_or_none(get_strn("AUTHORITY"))), + AUTHORITY=SkipNone( + _redact_url_or_none( + config.get_strn(xpath("authority"), xpath("authorityendpoint")) + ) + ), SCOPES=SkipNone(_redact_scopes(config.get_scopes(xpath("SCOPES")))), FEDERATED_CLIENT_ID=SkipNone( _redact_str_or_none(get_strn("FEDERATED_CLIENT_ID"), peek=True) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/agent_auth_configuration.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/agent_auth_configuration.py index ae7a211a3..09602c0dc 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/agent_auth_configuration.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/agent_auth_configuration.py @@ -7,13 +7,7 @@ from typing import Any -from microsoft_agents.activity.config import ( - coerce_bool, - coerce_int, - coerce_float, - _Configurable, - Configuration, -) +from microsoft_agents.activity.config import coerce_bool from microsoft_agents.hosting.core.authorization.auth_types import AuthTypes from microsoft_agents.hosting.core.authorization._entra_issuers import ( diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_manager.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_manager.py index 10f96026b..fcff27f15 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_manager.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_manager.py @@ -5,11 +5,11 @@ import logging import warnings -from typing import cast +from typing import cast, TypeVar from collections.abc import Callable from microsoft_agents.activity import Activity -from microsoft_agents.activity.config import Configuration, xpath, _get +from microsoft_agents.activity.config import Configuration, xpath, _get, path from .agent_auth_configuration import AgentAuthConfiguration from ..access_token_provider_base import AccessTokenProviderBase @@ -21,14 +21,7 @@ logger = logging.getLogger(__name__) - -def _factory( - func: Callable[[AgentAuthConfiguration], AccessTokenProviderBase], -) -> Callable[[Configuration], AccessTokenProviderBase]: - def wrapper(config: Configuration) -> AccessTokenProviderBase: - return func(AgentAuthConfiguration(**config)) - - return wrapper +ConfigT = TypeVar("ConfigT", Configuration, AgentAuthConfiguration) class ConnectionManager(Connections): @@ -87,6 +80,11 @@ def __init__( stacklevel=2, ) + provider_factory = cast( + Callable[[AgentAuthConfiguration], AccessTokenProviderBase], + provider_factory, + ) + if config is not None: raise ValueError( "Cannot provide both 'config' and deprecated connection configurations." @@ -113,14 +111,25 @@ def __init__( self._config_map[connection_name] = Configuration( **agent_auth_config.__dict__ ) - self._connections[connection_name] = self._provider_factory( + self._connections[connection_name] = provider_factory( agent_auth_config ) else: raw_configurations: dict[str, dict] = kwargs.get("CONNECTIONS", {}) - self._parse_raw_configurations( - raw_configurations, self._provider_factory - ) + for connection_name, connection_settings in raw_configurations.items(): + parsed_config = Configuration( + **_get(connection_settings, *xpath("SETTINGS"), default={}), + _base_path=path("CONNECTIONS", connection_name, "SETTINGS"), + ) + auth_config = AgentAuthConfiguration.from_dict(parsed_config) + self._connections[connection_name] = provider_factory(auth_config) + self._config_map[connection_name] = parsed_config + self._deprecated_config_map[connection_name] = auth_config + + for legacy_config in self._deprecated_config_map.values(): + # in legacy usage, this assignment is fine. + legacy_config._connections = self._deprecated_config_map + else: self._provider_factory = cast( Callable[[Configuration], AccessTokenProviderBase], provider_factory @@ -128,36 +137,25 @@ def __init__( config = Configuration.from_config(config, **kwargs) raw_connections_map = config.get(xpath("connections_map"), []) - raw_configurations = config.get(xpath("connections"), {}) - self._parse_raw_configurations(raw_configurations, self._provider_factory) + connection_configs = config.get_sections(*xpath("connections::*")) + + for section in connection_configs: + connection_name = section.key + parsed_config = section.get_section(xpath("settings"), default=section) + # parsed_config = Configuration(**_get(connection_settings, *xpath("SETTINGS"), default={})) + self._connections[connection_name] = self._provider_factory( + parsed_config + ) + self._config_map[connection_name] = parsed_config + self._deprecated_config_map[connection_name] = None self._connections_map = _ConnectionMap(raw_connections_map) - # JWT-patch - propagate the connections map to each agent auth configuration - for sub_config in self._config_map.values(): - sub_config["_connections"] = self._config_map - if not self._connections.get("SERVICE_CONNECTION", None): raise ValueError("No service connection configuration provided.") _log_config(logger, self._config_map, self._connections_map) - def _parse_raw_configurations( - self, - raw_configurations: dict[str, dict], - provider_factory: ( - Callable[[Configuration], AccessTokenProviderBase] - | Callable[[AgentAuthConfiguration], AccessTokenProviderBase] - ), - ) -> None: - for connection_name, connection_settings in raw_configurations.items(): - parsed_config = Configuration( - **_get(connection_settings, *xpath("SETTINGS"), default={}) - ) - self._connections[connection_name] = provider_factory(parsed_config) - self._config_map[connection_name] = parsed_config - self._deprecated_config_map[connection_name] = None - def get_connection(self, connection_name: str | None) -> AccessTokenProviderBase: """ Get the OAuth connection for the agent. @@ -280,7 +278,8 @@ def get_default_connection_configuration(self) -> AgentAuthConfiguration: :rtype: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` """ warnings.warn( - "This method is deprecated and will be removed in a future release.", + "ConnectionManager.get_default_connection_configuration() is deprecated; " + "use get_default_connection().config instead.", DeprecationWarning, stacklevel=2, ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_settings_base.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_settings_base.py index 56cc78a5c..aba0ba475 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_settings_base.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_settings_base.py @@ -38,12 +38,11 @@ def extras(self) -> Configuration: @classmethod def _from_agent_auth_config(cls, agent_auth_config: AgentAuthConfiguration) -> Self: - attrs = {} - for attr in agent_auth_config.__dict__: - if attr == "provider_settings": - attrs.update(getattr(agent_auth_config, attr)) - else: - attrs[attr] = getattr(agent_auth_config, attr) + attrs = dict(vars(agent_auth_config)) + attrs.pop("_connections", None) + attrs.pop("_configured_issuers", None) + attrs.update(attrs.pop("provider_settings", {})) + attrs["ISSUERS"] = agent_auth_config.ISSUERS return cls.from_mapping(attrs) @classmethod 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 8e65c78e8..b71cb2d8a 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 @@ -28,7 +28,7 @@ def _from_configuration( token_validation = configuration.get_section( xpath("token_validation"), default=Configuration() ) - audiences = token_validation.get_str_list(xpath("audience")) + 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/pytest.ini b/pytest.ini index 7e79e6e61..2c37d0253 100644 --- a/pytest.ini +++ b/pytest.ini @@ -14,6 +14,12 @@ filterwarnings = ignore:The bot client is deprecated and will be removed in a future release\.:DeprecationWarning ignore:The 'is_authenticated' parameter is deprecated and will be removed in future versions\.:DeprecationWarning ignore:The 'is_authenticated' property is deprecated and will be removed in future versions\.:DeprecationWarning + # Legacy authentication APIs remain covered until their removal. + ignore:AgentAuthConfiguration is deprecated and will be removed in .*:DeprecationWarning + ignore:AgentAuthCConfiguration is deprecated and will be removed in a future version\.:DeprecationWarning + ignore:Accessing the deprecated configuration\. This will be removed in future versions\.:DeprecationWarning + ignore:The fields 'connections_configurations' and 'connections_map' are deprecated .*:DeprecationWarning + ignore:ConnectionManager\.get_default_connection_configuration\(\) is deprecated.*:DeprecationWarning # pytest-asyncio warnings that are safe to ignore ignore:.*deprecated.*asyncio.*:DeprecationWarning:pytest_asyncio.* diff --git a/test_samples/agentic-test/src/agent.py b/test_samples/agentic-test/src/agent.py index d429d09a6..2d1914b8b 100644 --- a/test_samples/agentic-test/src/agent.py +++ b/test_samples/agentic-test/src/agent.py @@ -31,13 +31,13 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) diff --git a/test_samples/agentic-test/src/main.py b/test_samples/agentic-test/src/main.py index fc8eb7fe1..72d932a28 100644 --- a/test_samples/agentic-test/src/main.py +++ b/test_samples/agentic-test/src/main.py @@ -16,11 +16,11 @@ ms_agents_logger.setLevel(logging.DEBUG) -from agent import AGENT_APP, CONNECTION_MANAGER, ADAPTER +from agent import AGENT_APP, ADAPTER, agents_sdk_config from start_server import start_server start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/agentic-test/src/start_server.py b/test_samples/agentic-test/src/start_server.py index 1f2e3544b..c0e965561 100644 --- a/test_samples/agentic-test/src/start_server.py +++ b/test_samples/agentic-test/src/start_server.py @@ -1,5 +1,5 @@ -from os import environ -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration +from microsoft_agents.hosting.core import AgentApplication from microsoft_agents.hosting.aiohttp import ( start_agent_process, jwt_authorization_middleware, @@ -11,7 +11,7 @@ def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration: AgentAuthConfiguration + config: Configuration ): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -24,11 +24,11 @@ async def entry_point(req: Request) -> Response: APP = Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", entry_point) - APP["agent_configuration"] = auth_configuration + APP["agent_configuration"] = config APP["agent_app"] = agent_application APP["adapter"] = adapter try: - run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + run_app(APP, host="localhost", port=config.get_int("PORT", default=3978)) except Exception as error: raise error diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 00747608a..c3f877865 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -26,12 +26,12 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) @@ -300,5 +300,5 @@ async def on_error(context: TurnContext, error: Exception): start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/app_style/echo_proactive_agent.py b/test_samples/app_style/echo_proactive_agent.py index ca3af8e9f..322aa0f3c 100644 --- a/test_samples/app_style/echo_proactive_agent.py +++ b/test_samples/app_style/echo_proactive_agent.py @@ -118,13 +118,12 @@ def from_json_to_store_item( agents_sdk_config = load_configuration_from_env(environ) storage = MemoryStorage() -connection_manager = MsalConnectionManager(**agents_sdk_config) +connection_manager = MsalConnectionManager(config=agents_sdk_config) adapter = CloudAdapter(connection_manager=connection_manager) -authorization = Authorization(storage, connection_manager, **agents_sdk_config) +authorization = Authorization(storage, connection_manager, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( storage=storage, authorization=authorization, - **agents_sdk_config.get("AGENTAPPLICATION", {}), ) @@ -209,10 +208,7 @@ def create_app() -> web.Application: app["adapter"] = adapter app["agent_app"] = AGENT_APP app["echo_service"] = echo_service - agent_config = connection_manager.get_default_connection_configuration() - if not agent_config: - raise ValueError("SERVICE_CONNECTION settings are missing.") - app["agent_configuration"] = agent_config + app["agent_configuration"] = agents_sdk_config app.router.add_get("/", _handle_root) app.router.add_post("/api/messages", _agent_entry_point) @@ -273,8 +269,8 @@ def main() -> None: logging.basicConfig(level=logging.INFO) app = create_app() - host = environ.get("HOST", "localhost") - port = int(environ.get("PORT", "3978")) + host = agents_sdk_config.get_str("HOST", default="localhost") + port = agents_sdk_config.get_int("PORT", default=3978) web.run_app(app, host=host, port=port) diff --git a/test_samples/app_style/empty_agent.py b/test_samples/app_style/empty_agent.py index 709a3bdbb..b1fdcfa31 100644 --- a/test_samples/app_style/empty_agent.py +++ b/test_samples/app_style/empty_agent.py @@ -28,14 +28,14 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) @@ -61,7 +61,7 @@ async def on_message(context: TurnContext, _): start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) except Exception as error: raise error diff --git a/test_samples/app_style/mcs_agent.py b/test_samples/app_style/mcs_agent.py index f5d34674c..7ee5a60b6 100644 --- a/test_samples/app_style/mcs_agent.py +++ b/test_samples/app_style/mcs_agent.py @@ -3,7 +3,6 @@ import traceback from os import environ, path -from typing import Optional from dotenv import load_dotenv from microsoft_agents.hosting.core import ( @@ -20,6 +19,7 @@ ActivityTypes, Activity, ) +from microsoft_agents.activity.config import Configuration from microsoft_agents.copilotstudio.client import ( ConnectionSettings, CopilotClient, @@ -38,55 +38,39 @@ # Create storage and connection manager STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) class McsConnectionSettings(ConnectionSettings): - """Connection settings for MCS that loads from environment variables""" - - def __init__( - self, - app_client_id: Optional[str] = None, - tenant_id: Optional[str] = None, - environment_id: Optional[str] = None, - agent_identifier: Optional[str] = None, - cloud: Optional[PowerPlatformCloud] = None, - copilot_agent_type: Optional[AgentType] = None, - custom_power_platform_cloud: Optional[str] = None, - **kwargs: Optional[str], - ) -> None: - self.app_client_id = app_client_id or kwargs.get("AGENTAPPID") - self.tenant_id = tenant_id or kwargs.get("TENANTID") + """Connection settings for the COPILOTSTUDIOAGENT configuration section.""" + + def __init__(self, config: Configuration) -> None: + self.app_client_id = config.get_str("AGENTAPPID", default=None) + self.tenant_id = config.get_str("TENANTID", default=None) if not self.app_client_id: raise ValueError("Agent App ID must be provided") if not self.tenant_id: raise ValueError("Tenant ID must be provided") - environment_id = environment_id or kwargs.get("ENVIRONMENTID") - agent_identifier = agent_identifier or kwargs.get("SCHEMANAME") - cloud = cloud or PowerPlatformCloud[kwargs.get("CLOUD", "UNKNOWN")] - copilot_agent_type = ( - copilot_agent_type or AgentType[kwargs.get("COPILOTAGENTTYPE", "PUBLISHED")] - ) - custom_power_platform_cloud = custom_power_platform_cloud or kwargs.get( - "CUSTOMPOWERPLATFORMCLOUD", None - ) - super().__init__( - environment_id, - agent_identifier, - cloud, - copilot_agent_type, - custom_power_platform_cloud, + environment_id=config.get_str("ENVIRONMENTID", default=None), + agent_identifier=config.get_str("SCHEMANAME", default=None), + cloud=PowerPlatformCloud[config.get_str("CLOUD", default="UNKNOWN")], + copilot_agent_type=AgentType[ + config.get_str("COPILOTAGENTTYPE", default="PUBLISHED") + ], + custom_power_platform_cloud=config.get_str( + "CUSTOMPOWERPLATFORMCLOUD", default=None + ), ) # Create the agent instance AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) @@ -181,7 +165,7 @@ async def _status(context: TurnContext, state: TurnState) -> None: def _create_client(token: str) -> CopilotClient: """Create CopilotClient with connection settings from environment""" - settings = McsConnectionSettings(**agents_sdk_config.get("COPILOTSTUDIOAGENT", {})) + settings = McsConnectionSettings(agents_sdk_config.get_section("COPILOTSTUDIOAGENT")) return CopilotClient(settings, token) @@ -191,5 +175,5 @@ def _create_client(token: str) -> CopilotClient: start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/app_style/shared/start_server.py b/test_samples/app_style/shared/start_server.py index 6235fe8ba..64164b1ac 100644 --- a/test_samples/app_style/shared/start_server.py +++ b/test_samples/app_style/shared/start_server.py @@ -1,5 +1,5 @@ -from os import environ -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration +from microsoft_agents.hosting.core import AgentApplication from microsoft_agents.hosting.aiohttp import ( start_agent_process, jwt_authorization_middleware, @@ -11,7 +11,7 @@ def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration: AgentAuthConfiguration + config: Configuration ): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -25,11 +25,11 @@ async def entry_point(req: Request) -> Response: APP = Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", entry_point) APP.router.add_get("/api/messages", lambda _: Response(status=200)) - APP["agent_configuration"] = auth_configuration + APP["agent_configuration"] = config APP["agent_app"] = agent_application APP["adapter"] = adapter try: - run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + run_app(APP, host="localhost", port=config.get_int("PORT", default=3978)) except Exception as error: raise error diff --git a/test_samples/app_style/streaming_agent.py b/test_samples/app_style/streaming_agent.py index 82f9ca96a..4a9d0a5ad 100644 --- a/test_samples/app_style/streaming_agent.py +++ b/test_samples/app_style/streaming_agent.py @@ -31,12 +31,12 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) @@ -112,5 +112,5 @@ async def on_error(context: TurnContext, error: Exception): start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/cards/agent.py b/test_samples/cards/agent.py index cff7fc081..92b7e8519 100644 --- a/test_samples/cards/agent.py +++ b/test_samples/cards/agent.py @@ -48,16 +48,15 @@ config = load_configuration_from_env(environ) storage = MemoryStorage() -connection_manager = MsalConnectionManager(**config) +connection_manager = MsalConnectionManager(config=config) adapter = CloudAdapter(connection_manager=connection_manager) -authorization = Authorization(storage, connection_manager, **config) +authorization = Authorization(storage, connection_manager, config=config) app = AgentApplication[TurnState]( storage=storage, authorization=authorization, start_typing_timer=False, remove_recipient_mention=False, - **config, ) @@ -176,7 +175,5 @@ async def on_message(context: TurnContext, _state: TurnState) -> None: start_server( agent_application=app, adapter=adapter, - auth_configuration=( - connection_manager.get_default_connection_configuration() - ), + config=config, ) diff --git a/test_samples/cards/start_server.py b/test_samples/cards/start_server.py index 81f17caa3..018ea5556 100644 --- a/test_samples/cards/start_server.py +++ b/test_samples/cards/start_server.py @@ -1,22 +1,21 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from os import environ - from aiohttp.web import Application, Request, Response, run_app +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.aiohttp import ( CloudAdapter, jwt_authorization_middleware, start_agent_process, ) -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.core import AgentApplication def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration: AgentAuthConfiguration, + config: Configuration, ) -> None: async def entry_point(request: Request) -> Response: adapter: CloudAdapter = request.app["adapter"] @@ -26,8 +25,8 @@ async def entry_point(request: Request) -> Response: web_app = Application(middlewares=[jwt_authorization_middleware]) web_app.router.add_post("/api/messages", entry_point) web_app.router.add_get("/", lambda _: Response(text="Cards sample")) - web_app["agent_configuration"] = auth_configuration + web_app["agent_configuration"] = config web_app["agent_app"] = agent_application web_app["adapter"] = adapter - run_app(web_app, host="localhost", port=int(environ.get("PORT", 3978))) + run_app(web_app, host="localhost", port=config.get_int("PORT", default=3978)) diff --git a/test_samples/compat/dialogs/complex_dialog/src/main.py b/test_samples/compat/dialogs/complex_dialog/src/main.py index f7f51380b..0a2ec39dc 100644 --- a/test_samples/compat/dialogs/complex_dialog/src/main.py +++ b/test_samples/compat/dialogs/complex_dialog/src/main.py @@ -19,10 +19,10 @@ from .dialog_and_welcome_agent import DialogAndWelcomeAgent load_dotenv(path.join(path.dirname(__file__), "..", ".env")) -agents_sdk_config = load_configuration_from_env(dict(environ)) +agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) CONVERSATION_STATE = ConversationState(STORAGE) diff --git a/test_samples/compat/dialogs/custom_dialogs/src/main.py b/test_samples/compat/dialogs/custom_dialogs/src/main.py index a84226c72..feb60ecfa 100644 --- a/test_samples/compat/dialogs/custom_dialogs/src/main.py +++ b/test_samples/compat/dialogs/custom_dialogs/src/main.py @@ -19,10 +19,10 @@ from .agent import DialogAgent load_dotenv(path.join(path.dirname(__file__), "..", ".env")) -agents_sdk_config = load_configuration_from_env(dict(environ)) +agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) CONVERSATION_STATE = ConversationState(STORAGE) diff --git a/test_samples/compat/dialogs/multi_turn/src/main.py b/test_samples/compat/dialogs/multi_turn/src/main.py index b054d7550..f97db241a 100644 --- a/test_samples/compat/dialogs/multi_turn/src/main.py +++ b/test_samples/compat/dialogs/multi_turn/src/main.py @@ -19,10 +19,10 @@ from .agent import DialogAgent load_dotenv(path.join(path.dirname(__file__), "..", ".env")) -agents_sdk_config = load_configuration_from_env(dict(environ)) +agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) CONVERSATION_STATE = ConversationState(STORAGE) diff --git a/test_samples/compat/dialogs/oauth_prompt/src/main.py b/test_samples/compat/dialogs/oauth_prompt/src/main.py index 4a19fe461..491abfdd7 100644 --- a/test_samples/compat/dialogs/oauth_prompt/src/main.py +++ b/test_samples/compat/dialogs/oauth_prompt/src/main.py @@ -19,10 +19,10 @@ from .agent import DialogAgent load_dotenv(path.join(path.dirname(__file__), "..", ".env")) -agents_sdk_config = load_configuration_from_env(dict(environ)) +agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) CONVERSATION_STATE = ConversationState(STORAGE) @@ -39,7 +39,7 @@ async def messages(req: Request) -> Response: APP = web.Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", messages) -APP["agent_configuration"] = CONNECTION_MANAGER.get_default_connection_configuration() +APP["agent_configuration"] = agents_sdk_config APP["agent_app"] = AGENT APP["adapter"] = ADAPTER diff --git a/test_samples/compat/dialogs/user_auth/src/main.py b/test_samples/compat/dialogs/user_auth/src/main.py index 2c476d92f..9ea304367 100644 --- a/test_samples/compat/dialogs/user_auth/src/main.py +++ b/test_samples/compat/dialogs/user_auth/src/main.py @@ -19,10 +19,10 @@ from .auth_agent import AuthAgent load_dotenv(path.join(path.dirname(__file__), "..", ".env")) -agents_sdk_config = load_configuration_from_env(dict(environ)) +agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) CONVERSATION_STATE = ConversationState(STORAGE) @@ -39,7 +39,7 @@ async def messages(req: Request) -> Response: APP = web.Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", messages) -APP["agent_configuration"] = CONNECTION_MANAGER.get_default_connection_configuration() +APP["agent_configuration"] = agents_sdk_config APP["agent_app"] = AGENT APP["adapter"] = ADAPTER diff --git a/test_samples/compat/teams_agent/app.py b/test_samples/compat/teams_agent/app.py index a479d439a..ebdd12955 100644 --- a/test_samples/compat/teams_agent/app.py +++ b/test_samples/compat/teams_agent/app.py @@ -23,9 +23,9 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) USER_STATE = UserState(STORAGE) @@ -95,7 +95,7 @@ async def render_page(req: Request, page_name: str) -> Response: if static_path.exists(): APP.router.add_static("/public", static_path) -APP["agent_configuration"] = CONFIG +APP["agent_configuration"] = agents_sdk_config APP["adapter"] = ADAPTER if __name__ == "__main__": diff --git a/test_samples/copilot_studio_client_sample/app.py b/test_samples/copilot_studio_client_sample/app.py index 391787a06..c7f935c4d 100644 --- a/test_samples/copilot_studio_client_sample/app.py +++ b/test_samples/copilot_studio_client_sample/app.py @@ -3,7 +3,9 @@ from os import environ, path from msal import PublicClientApplication -from microsoft_agents.copilotstudio.client import ConnectionSettings, CopilotClient +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.activity.config import Configuration +from microsoft_agents.copilotstudio.client import CopilotClient from msal_cache_plugin import get_msal_token_cache from chat_console_service import ChatConsoleService @@ -42,10 +44,11 @@ def acquire_token(mcs_settings: McsConnectionSettings, cache_path: str) -> str: raise Exception("Authentication with the Public AgentApplication failed") -def create_mcs_client(connection_settings: ConnectionSettings) -> CopilotClient: +def create_mcs_client(config: Configuration) -> CopilotClient: + connection_settings = McsConnectionSettings(config) token = acquire_token( connection_settings, - environ.get("TOKEN_CACHE_PATH") + config.get_str("TOKEN_CACHE_PATH", default=None) or path.join(path.dirname(__file__), "bin/token_cache.bin"), ) return CopilotClient(connection_settings, token) @@ -53,8 +56,8 @@ def create_mcs_client(connection_settings: ConnectionSettings) -> CopilotClient: def main() -> None: load_dotenv(path.join(path.dirname(__file__), ".env")) - connection_settings = McsConnectionSettings() - client = create_mcs_client(connection_settings) + config = load_configuration_from_env(environ) + client = create_mcs_client(config) asyncio.run(ChatConsoleService(client).start_service()) diff --git a/test_samples/copilot_studio_client_sample/config.py b/test_samples/copilot_studio_client_sample/config.py index a2da0dc66..c45e84b69 100644 --- a/test_samples/copilot_studio_client_sample/config.py +++ b/test_samples/copilot_studio_client_sample/config.py @@ -1,6 +1,4 @@ -from os import environ -from typing import Optional - +from microsoft_agents.activity.config import Configuration from microsoft_agents.copilotstudio.client import ( ConnectionSettings, PowerPlatformCloud, @@ -9,38 +7,28 @@ class McsConnectionSettings(ConnectionSettings): - def __init__( - self, - app_client_id: Optional[str] = None, - tenant_id: Optional[str] = None, - environment_id: Optional[str] = None, - agent_identifier: Optional[str] = None, - cloud: Optional[PowerPlatformCloud] = None, - copilot_agent_type: Optional[AgentType] = None, - custom_power_platform_cloud: Optional[str] = None, - direct_connect_url: Optional[str] = None, - use_experimental_endpoint: Optional[bool] = None, - enable_diagnostics: Optional[bool] = None, - ) -> None: - self.app_client_id = app_client_id or environ.get("APP_CLIENT_ID") - self.tenant_id = tenant_id or environ.get("TENANT_ID") + def __init__(self, config: Configuration) -> None: + self.app_client_id = config.get_str("APP_CLIENT_ID", default=None) + self.tenant_id = config.get_str("TENANT_ID", default=None) if not self.app_client_id: raise ValueError("App Client ID must be provided") if not self.tenant_id: raise ValueError("Tenant ID must be provided") - # Use the parent class's method to populate settings from environment - settings = ConnectionSettings.populate_from_environment( - environment_id=environment_id, - agent_identifier=agent_identifier, - cloud=cloud, - copilot_agent_type=copilot_agent_type, - custom_power_platform_cloud=custom_power_platform_cloud, - direct_connect_url=direct_connect_url, - use_experimental_endpoint=use_experimental_endpoint, - enable_diagnostics=enable_diagnostics, + super().__init__( + environment_id=config.get_str("ENVIRONMENT_ID", default=""), + agent_identifier=config.get_str("AGENT_IDENTIFIER", default=""), + cloud=PowerPlatformCloud[config.get_str("CLOUD", default="PROD")], + copilot_agent_type=AgentType[ + config.get_str("COPILOT_AGENT_TYPE", default="PUBLISHED") + ], + custom_power_platform_cloud=config.get_str( + "CUSTOM_POWER_PLATFORM_CLOUD", default=None + ), + direct_connect_url=config.get_str("DIRECT_CONNECT_URL", default=None), + use_experimental_endpoint=config.get_bool( + "USE_EXPERIMENTAL_ENDPOINT", default=False + ), + enable_diagnostics=config.get_bool("ENABLE_DIAGNOSTICS", default=False), ) - - # Initialize the parent class with the populated settings - super().__init__(**settings) diff --git a/test_samples/copilot_studio_connector/app.py b/test_samples/copilot_studio_connector/app.py index b8970c74a..0a6c5a170 100644 --- a/test_samples/copilot_studio_connector/app.py +++ b/test_samples/copilot_studio_connector/app.py @@ -41,15 +41,14 @@ # Create storage and connection manager STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) # Create the agent instance AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, authorization=AUTHORIZATION, - **agents_sdk_config.get("AGENTAPPLICATION", {}), ) @@ -157,14 +156,12 @@ async def entry_point(req: Request) -> Response: APP = Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", entry_point) APP.router.add_get("/api/messages", lambda _: Response(status=200)) - APP["agent_configuration"] = ( - CONNECTION_MANAGER.get_default_connection_configuration() - ) + APP["agent_configuration"] = agents_sdk_config APP["agent_app"] = AGENT_APP APP["adapter"] = ADAPTER - host = environ.get("HOST", "localhost") - port = int(environ.get("PORT", "3978")) + host = agents_sdk_config.get_str("HOST", default="localhost") + port = agents_sdk_config.get_int("PORT", default=3978) logger.info(f"Starting Copilot Studio Connector sample on {host}:{port}") run_app(APP, host=host, port=port) diff --git a/test_samples/entra_sidecar/agent.py b/test_samples/entra_sidecar/agent.py index 27d47f486..fc6b8b6ab 100644 --- a/test_samples/entra_sidecar/agent.py +++ b/test_samples/entra_sidecar/agent.py @@ -16,7 +16,6 @@ from aiohttp.web import Request, Response, Application, middleware, run_app from microsoft_agents.activity import load_configuration_from_env -from microsoft_agents.activity.config._coercion import coerce_bool from microsoft_agents.authentication.entra_auth_sidecar import SidecarAuth from microsoft_agents.hosting.core import ( AgentApplication, @@ -43,20 +42,20 @@ # validation is DISABLED by default so the sample runs locally against the # sidecar without a real tenant/app registration. Set TOKENVALIDATION__ENABLED=true # (and a real CLIENTID/TENANTID) for any non-local deployment. -TOKEN_VALIDATION_ENABLED = coerce_bool( - environ.get("TOKENVALIDATION__ENABLED", "false"), default=False +TOKEN_VALIDATION_ENABLED = agents_sdk_config.get_bool( + ["TOKENVALIDATION", "ENABLED"], default=False ) STORAGE = MemoryStorage() # No secrets/certificates required: the sidecar owns all credential management. CONNECTION_MANAGER = ConnectionManager( - provider_factory=SidecarAuth, **agents_sdk_config + provider_factory=SidecarAuth, config=agents_sdk_config ) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) @@ -110,16 +109,14 @@ async def entry_point(req: Request) -> Response: app.router.add_post("/api/messages", entry_point) app.router.add_get("/api/messages", lambda _: Response(status=200)) app.router.add_get("/health", _health) - app["agent_configuration"] = ( - CONNECTION_MANAGER.get_default_connection_configuration() - ) + app["agent_configuration"] = agents_sdk_config app["agent_app"] = agent_application app["adapter"] = adapter run_app( app, - host=environ.get("HOST", "localhost"), - port=int(environ.get("PORT", 3978)), + host=agents_sdk_config.get_str("HOST", default="localhost"), + port=agents_sdk_config.get_int("PORT", default=3978), ) diff --git a/test_samples/extensions/extension-starter/src/sample/app.py b/test_samples/extensions/extension-starter/src/sample/app.py index 62bc02d44..097b36df9 100644 --- a/test_samples/extensions/extension-starter/src/sample/app.py +++ b/test_samples/extensions/extension-starter/src/sample/app.py @@ -23,11 +23,11 @@ # Create storage and connection manager STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) diff --git a/test_samples/extensions/extension-starter/src/sample/main.py b/test_samples/extensions/extension-starter/src/sample/main.py index 674409de6..685b6ee57 100644 --- a/test_samples/extensions/extension-starter/src/sample/main.py +++ b/test_samples/extensions/extension-starter/src/sample/main.py @@ -9,12 +9,12 @@ ms_agents_logger.addHandler(logging.StreamHandler()) ms_agents_logger.setLevel(logging.INFO) -from .app import CONNECTION_MANAGER, ADAPTER +from .app import ADAPTER, agents_sdk_config from .extension_agent import APP from .start_server import start_server start_server( agent_application=APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/extensions/extension-starter/src/sample/start_server.py b/test_samples/extensions/extension-starter/src/sample/start_server.py index d926497e7..5d3ce9a03 100644 --- a/test_samples/extensions/extension-starter/src/sample/start_server.py +++ b/test_samples/extensions/extension-starter/src/sample/start_server.py @@ -1,5 +1,5 @@ -from os import environ -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration +from microsoft_agents.hosting.core import AgentApplication from microsoft_agents.hosting.aiohttp import ( start_agent_process, jwt_authorization_middleware, @@ -9,7 +9,7 @@ def start_server( - agent_application: AgentApplication, adapter: CloudAdapter, auth_configuration: AgentAuthConfiguration + agent_application: AgentApplication, adapter: CloudAdapter, config: Configuration ): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -22,11 +22,11 @@ async def entry_point(req: Request) -> Response: APP = Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", entry_point) - APP["agent_configuration"] = auth_configuration + APP["agent_configuration"] = config APP["agent_app"] = agent_application APP["adapter"] = adapter try: - run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + run_app(APP, host="localhost", port=config.get_int("PORT", default=3978)) except Exception as error: raise error diff --git a/test_samples/extensions/slack-agent/src/app.py b/test_samples/extensions/slack-agent/src/app.py index ce360047b..f6963dbc7 100644 --- a/test_samples/extensions/slack-agent/src/app.py +++ b/test_samples/extensions/slack-agent/src/app.py @@ -19,11 +19,10 @@ agents_sdk_config = load_configuration_from_env(os.environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) APP = AgentApplication[TurnState]( storage=STORAGE, authorization=AUTHORIZATION, - **agents_sdk_config, ) diff --git a/test_samples/extensions/slack-agent/src/main.py b/test_samples/extensions/slack-agent/src/main.py index 19ea03177..1590471a8 100644 --- a/test_samples/extensions/slack-agent/src/main.py +++ b/test_samples/extensions/slack-agent/src/main.py @@ -8,11 +8,11 @@ ms_agents_logger.setLevel(logging.INFO) from .agent import APP # noqa: E402 (side-effect imports register routes) -from .app import CONNECTION_MANAGER, ADAPTER # noqa: E402 +from .app import ADAPTER, agents_sdk_config # noqa: E402 from .start_server import start_server # noqa: E402 start_server( agent_application=APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/extensions/slack-agent/src/start_server.py b/test_samples/extensions/slack-agent/src/start_server.py index cc93a8872..3bf55b7b7 100644 --- a/test_samples/extensions/slack-agent/src/start_server.py +++ b/test_samples/extensions/slack-agent/src/start_server.py @@ -1,22 +1,21 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from os import environ - from aiohttp.web import Application, Request, Response, run_app +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.aiohttp import ( CloudAdapter, jwt_authorization_middleware, start_agent_process, ) -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.core import AgentApplication def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration: AgentAuthConfiguration, + config: Configuration, ) -> None: async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -25,8 +24,8 @@ async def entry_point(req: Request) -> Response: app = Application(middlewares=[jwt_authorization_middleware]) app.router.add_post("/api/messages", entry_point) - app["agent_configuration"] = auth_configuration + app["agent_configuration"] = config app["agent_app"] = agent_application app["adapter"] = adapter - run_app(app, host="localhost", port=environ.get("PORT", 3978)) + run_app(app, host="localhost", port=config.get_int("PORT", default=3978)) diff --git a/test_samples/fastapi/authorization_agent.py b/test_samples/fastapi/authorization_agent.py index 5e465a10d..bdb82d003 100644 --- a/test_samples/fastapi/authorization_agent.py +++ b/test_samples/fastapi/authorization_agent.py @@ -40,12 +40,12 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) @@ -138,9 +138,7 @@ async def message(context: TurnContext, state: TurnState) -> None: # Create FastAPI app app = FastAPI(title="Authorization Agent Sample", version="1.0.0") -app.state.agent_configuration = ( - CONNECTION_MANAGER.get_default_connection_configuration() -) +app.state.agent_configuration = agents_sdk_config # FastAPI routes @@ -165,5 +163,5 @@ async def messages_get(): if __name__ == "__main__": - port = int(environ.get("PORT", 3978)) + port = agents_sdk_config.get_int("PORT", default=3978) uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/test_samples/fastapi/empty_agent.py b/test_samples/fastapi/empty_agent.py index 0ffa6094e..eadf478cd 100644 --- a/test_samples/fastapi/empty_agent.py +++ b/test_samples/fastapi/empty_agent.py @@ -29,19 +29,17 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) # Create FastAPI app app = FastAPI(title="Empty Agent Sample", version="1.0.0") -app.state.agent_configuration = ( - CONNECTION_MANAGER.get_default_connection_configuration() -) +app.state.agent_configuration = agents_sdk_config # Agent handlers @@ -84,5 +82,5 @@ async def messages_get(): if __name__ == "__main__": - port = int(environ.get("PORT", 3978)) + port = agents_sdk_config.get_int("PORT", default=3978) uvicorn.run(app, host="127.0.0.1", port=port) diff --git a/test_samples/handling_attachments/src/agent.py b/test_samples/handling_attachments/src/agent.py index 153b12bef..76ea3635a 100644 --- a/test_samples/handling_attachments/src/agent.py +++ b/test_samples/handling_attachments/src/agent.py @@ -45,9 +45,9 @@ agents_sdk_config = load_configuration_from_env(os.environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, @@ -57,7 +57,6 @@ AttachmentDownloader(), M365AttachmentDownloader(connections=CONNECTION_MANAGER), ], - **agents_sdk_config, ) diff --git a/test_samples/handling_attachments/src/main.py b/test_samples/handling_attachments/src/main.py index af6fb7115..7090079c8 100644 --- a/test_samples/handling_attachments/src/main.py +++ b/test_samples/handling_attachments/src/main.py @@ -1,5 +1,3 @@ -import os - import uvicorn from fastapi import FastAPI, Request @@ -8,15 +6,13 @@ jwt_authorization_decorator, ) -from .agent import AGENT_APP, ADAPTER, CONNECTION_MANAGER +from .agent import AGENT_APP, ADAPTER, agents_sdk_config if __name__ == "__main__": app = FastAPI(title="Handling Attachments Agent", version="1.0.0") - app.state.agent_configuration = ( - CONNECTION_MANAGER.get_default_connection_configuration() - ) + app.state.agent_configuration = agents_sdk_config @app.post("/api/messages") @jwt_authorization_decorator @@ -31,5 +27,5 @@ async def messages_handler( ADAPTER, ) - port = int(os.environ.get("PORT", 3978)) + port = agents_sdk_config.get_int("PORT", default=3978) uvicorn.run(app, host="127.0.0.1", port=port) diff --git a/test_samples/hosting_msteams/conversation-agent/src/agent.py b/test_samples/hosting_msteams/conversation-agent/src/agent.py index a5ff6b2cc..e81218c09 100644 --- a/test_samples/hosting_msteams/conversation-agent/src/agent.py +++ b/test_samples/hosting_msteams/conversation-agent/src/agent.py @@ -58,15 +58,14 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, authorization=AUTHORIZATION, - **agents_sdk_config, ) teams = TeamsAgentExtension[TurnState](AGENT_APP) diff --git a/test_samples/hosting_msteams/conversation-agent/src/main.py b/test_samples/hosting_msteams/conversation-agent/src/main.py index b80f346a5..8b9bc89d1 100644 --- a/test_samples/hosting_msteams/conversation-agent/src/main.py +++ b/test_samples/hosting_msteams/conversation-agent/src/main.py @@ -1,12 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .agent import AGENT_APP, CONNECTION_MANAGER, ADAPTER +from .agent import AGENT_APP, ADAPTER, agents_sdk_config from .start_server import start_server if __name__ == "__main__": start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/hosting_msteams/conversation-agent/src/start_server.py b/test_samples/hosting_msteams/conversation-agent/src/start_server.py index 5c5b53b8d..6c3369ab5 100644 --- a/test_samples/hosting_msteams/conversation-agent/src/start_server.py +++ b/test_samples/hosting_msteams/conversation-agent/src/start_server.py @@ -1,9 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from os import environ - from aiohttp.web import Application, Request, Response, run_app +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.aiohttp import ( CloudAdapter, @@ -16,7 +15,7 @@ def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration, + config: Configuration, ) -> None: async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -26,8 +25,8 @@ async def entry_point(req: Request) -> Response: app = Application(middlewares=[jwt_authorization_middleware]) app.router.add_post("/api/messages", entry_point) app.router.add_get("/api/messages", lambda _: Response(status=200)) - app["agent_configuration"] = auth_configuration + app["agent_configuration"] = config app["agent_app"] = agent_application app["adapter"] = adapter - run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) + run_app(app, host="localhost", port=config.get_int("PORT", default=3978)) diff --git a/test_samples/hosting_msteams/graph-clients/src/agent.py b/test_samples/hosting_msteams/graph-clients/src/agent.py index 3cd63ddb7..381e186f0 100644 --- a/test_samples/hosting_msteams/graph-clients/src/agent.py +++ b/test_samples/hosting_msteams/graph-clients/src/agent.py @@ -26,14 +26,13 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, authorization=AUTHORIZATION, - **agents_sdk_config, ) teams = TeamsAgentExtension[TurnState](AGENT_APP) diff --git a/test_samples/hosting_msteams/graph-clients/src/main.py b/test_samples/hosting_msteams/graph-clients/src/main.py index bbc024c0c..2914ad2c5 100644 --- a/test_samples/hosting_msteams/graph-clients/src/main.py +++ b/test_samples/hosting_msteams/graph-clients/src/main.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .agent import AGENT_APP, CONNECTION_MANAGER, ADAPTER +from .agent import AGENT_APP, ADAPTER, agents_sdk_config from .start_server import start_server @@ -9,5 +9,5 @@ start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/hosting_msteams/graph-clients/src/start_server.py b/test_samples/hosting_msteams/graph-clients/src/start_server.py index 5c5b53b8d..6c3369ab5 100644 --- a/test_samples/hosting_msteams/graph-clients/src/start_server.py +++ b/test_samples/hosting_msteams/graph-clients/src/start_server.py @@ -1,9 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from os import environ - from aiohttp.web import Application, Request, Response, run_app +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.aiohttp import ( CloudAdapter, @@ -16,7 +15,7 @@ def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration, + config: Configuration, ) -> None: async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -26,8 +25,8 @@ async def entry_point(req: Request) -> Response: app = Application(middlewares=[jwt_authorization_middleware]) app.router.add_post("/api/messages", entry_point) app.router.add_get("/api/messages", lambda _: Response(status=200)) - app["agent_configuration"] = auth_configuration + app["agent_configuration"] = config app["agent_app"] = agent_application app["adapter"] = adapter - run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) + run_app(app, host="localhost", port=config.get_int("PORT", default=3978)) diff --git a/test_samples/hosting_msteams/message-extensions/src/agent.py b/test_samples/hosting_msteams/message-extensions/src/agent.py index 335fa754c..e3df57f9c 100644 --- a/test_samples/hosting_msteams/message-extensions/src/agent.py +++ b/test_samples/hosting_msteams/message-extensions/src/agent.py @@ -50,18 +50,19 @@ agents_sdk_config = load_configuration_from_env(environ) # URL exposed by the agent (dev tunnel / public host) used by the settings command. -SETTINGS_URL = environ.get("SETTINGS_URL", "http://localhost:3978/settings") +SETTINGS_URL = agents_sdk_config.get_str( + "SETTINGS_URL", default="http://localhost:3978/settings" +) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, authorization=AUTHORIZATION, - **agents_sdk_config, ) teams = TeamsAgentExtension[TurnState](AGENT_APP) diff --git a/test_samples/hosting_msteams/message-extensions/src/main.py b/test_samples/hosting_msteams/message-extensions/src/main.py index b80f346a5..8b9bc89d1 100644 --- a/test_samples/hosting_msteams/message-extensions/src/main.py +++ b/test_samples/hosting_msteams/message-extensions/src/main.py @@ -1,12 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .agent import AGENT_APP, CONNECTION_MANAGER, ADAPTER +from .agent import AGENT_APP, ADAPTER, agents_sdk_config from .start_server import start_server if __name__ == "__main__": start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/hosting_msteams/message-extensions/src/start_server.py b/test_samples/hosting_msteams/message-extensions/src/start_server.py index ce3813dc8..7e4eac09f 100644 --- a/test_samples/hosting_msteams/message-extensions/src/start_server.py +++ b/test_samples/hosting_msteams/message-extensions/src/start_server.py @@ -1,10 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from os import environ from pathlib import Path from aiohttp.web import Application, FileResponse, Request, Response, run_app +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.aiohttp import ( CloudAdapter, @@ -19,7 +19,7 @@ def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration, + config: Configuration, ) -> None: @jwt_authorization_decorator async def entry_point(req: Request): @@ -37,8 +37,8 @@ async def health_check(_: Request) -> Response: app.router.add_post("/api/messages", entry_point) app.router.add_get("/api/messages", health_check) app.router.add_get("/settings", serve_settings) - app["agent_configuration"] = auth_configuration + app["agent_configuration"] = config app["agent_app"] = agent_application app["adapter"] = adapter - run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) + run_app(app, host="localhost", port=config.get_int("PORT", default=3978)) diff --git a/test_samples/hosting_msteams/task-modules/src/agent.py b/test_samples/hosting_msteams/task-modules/src/agent.py index f31f36b51..7bc70810e 100644 --- a/test_samples/hosting_msteams/task-modules/src/agent.py +++ b/test_samples/hosting_msteams/task-modules/src/agent.py @@ -41,18 +41,17 @@ agents_sdk_config = load_configuration_from_env(environ) # Public base URL of this agent (dev tunnel / host) used for the webpage dialog. -APP_BASE_URL = environ.get("APP_BASE_URL", "http://localhost:3978") +APP_BASE_URL = agents_sdk_config.get_str("APP_BASE_URL", default="http://localhost:3978") STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, authorization=AUTHORIZATION, - **agents_sdk_config, ) teams = TeamsAgentExtension[TurnState](AGENT_APP) diff --git a/test_samples/hosting_msteams/task-modules/src/main.py b/test_samples/hosting_msteams/task-modules/src/main.py index b80f346a5..8b9bc89d1 100644 --- a/test_samples/hosting_msteams/task-modules/src/main.py +++ b/test_samples/hosting_msteams/task-modules/src/main.py @@ -1,12 +1,12 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .agent import AGENT_APP, CONNECTION_MANAGER, ADAPTER +from .agent import AGENT_APP, ADAPTER, agents_sdk_config from .start_server import start_server if __name__ == "__main__": start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/hosting_msteams/task-modules/src/start_server.py b/test_samples/hosting_msteams/task-modules/src/start_server.py index 6d48072ae..5ea34ea72 100644 --- a/test_samples/hosting_msteams/task-modules/src/start_server.py +++ b/test_samples/hosting_msteams/task-modules/src/start_server.py @@ -1,9 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from os import environ - from aiohttp.web import Application, Request, Response, middleware, run_app +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.aiohttp import ( CloudAdapter, @@ -29,7 +28,7 @@ async def _auth_unless_public(request: Request, handler): def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration, + config: Configuration, ) -> None: async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -47,8 +46,8 @@ async def dialog_form(_req: Request) -> Response: app.router.add_get("/api/messages", lambda _: Response(status=200)) # Served as the URL content for the "Webpage Dialog" task module. app.router.add_get("/dialog-form", dialog_form) - app["agent_configuration"] = auth_configuration + app["agent_configuration"] = config app["agent_app"] = agent_application app["adapter"] = adapter - run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) + run_app(app, host="localhost", port=config.get_int("PORT", default=3978)) diff --git a/test_samples/otel/quickstart/src/agent.py b/test_samples/otel/quickstart/src/agent.py index e295af7c8..b5a34e5e8 100644 --- a/test_samples/otel/quickstart/src/agent.py +++ b/test_samples/otel/quickstart/src/agent.py @@ -26,13 +26,13 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) diff --git a/test_samples/otel/quickstart/src/main.py b/test_samples/otel/quickstart/src/main.py index de99da414..3a9510179 100644 --- a/test_samples/otel/quickstart/src/main.py +++ b/test_samples/otel/quickstart/src/main.py @@ -5,11 +5,11 @@ configure_otel_providers(service_name="quickstart_agent") -from .agent import AGENT_APP, CONNECTION_MANAGER, ADAPTER +from .agent import AGENT_APP, ADAPTER, agents_sdk_config from .start_server import start_server start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/otel/quickstart/src/start_server.py b/test_samples/otel/quickstart/src/start_server.py index dac9044bf..6d8954c36 100644 --- a/test_samples/otel/quickstart/src/start_server.py +++ b/test_samples/otel/quickstart/src/start_server.py @@ -1,7 +1,7 @@ -from os import environ import logging -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration +from microsoft_agents.hosting.core import AgentApplication from microsoft_agents.hosting.aiohttp import ( start_agent_process, CloudAdapter, @@ -15,7 +15,7 @@ def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration: AgentAuthConfiguration + config: Configuration ): async def entry_point(req: Request) -> Response: @@ -32,8 +32,8 @@ async def entry_point(req: Request) -> Response: APP = Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", entry_point) - APP["agent_configuration"] = auth_configuration + APP["agent_configuration"] = config APP["agent_app"] = agent_application APP["adapter"] = adapter - run_app(APP, host="localhost", port=int(environ.get("PORT", 3978))) + run_app(APP, host="localhost", port=config.get_int("PORT", default=3978)) diff --git a/test_samples/otel/zero-code/src/agent.py b/test_samples/otel/zero-code/src/agent.py index a8855c0a2..07537d163 100644 --- a/test_samples/otel/zero-code/src/agent.py +++ b/test_samples/otel/zero-code/src/agent.py @@ -27,12 +27,12 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) diff --git a/test_samples/otel/zero-code/src/main.py b/test_samples/otel/zero-code/src/main.py index 3026f5eeb..156b24462 100644 --- a/test_samples/otel/zero-code/src/main.py +++ b/test_samples/otel/zero-code/src/main.py @@ -1,11 +1,11 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .agent import AGENT_APP, CONNECTION_MANAGER, ADAPTER +from .agent import AGENT_APP, ADAPTER, agents_sdk_config from .start_server import start_server start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/otel/zero-code/src/start_server.py b/test_samples/otel/zero-code/src/start_server.py index 8cd34eb6e..3712c9896 100644 --- a/test_samples/otel/zero-code/src/start_server.py +++ b/test_samples/otel/zero-code/src/start_server.py @@ -1,5 +1,4 @@ import logging -from os import environ from aiohttp.web import Application, Request, Response, run_app from microsoft_agents.hosting.aiohttp import ( @@ -7,7 +6,8 @@ jwt_authorization_middleware, start_agent_process, ) -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration +from microsoft_agents.hosting.core import AgentApplication logger = logging.getLogger(__name__) @@ -15,7 +15,7 @@ def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration: AgentAuthConfiguration + config: Configuration ): async def entry_point(req: Request) -> Response: logger.info("Request received at /api/messages endpoint.") @@ -27,8 +27,8 @@ async def entry_point(req: Request) -> Response: app = Application(middlewares=[jwt_authorization_middleware]) app.router.add_post("/api/messages", entry_point) - app["agent_configuration"] = auth_configuration + app["agent_configuration"] = config app["agent_app"] = agent_application app["adapter"] = adapter - run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) + run_app(app, host="localhost", port=config.get_int("PORT", default=3978)) diff --git a/test_samples/proactive/proactive_agent.py b/test_samples/proactive/proactive_agent.py index 292905bfb..21b6202bb 100644 --- a/test_samples/proactive/proactive_agent.py +++ b/test_samples/proactive/proactive_agent.py @@ -62,12 +62,12 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) # Authorization is built from agents_sdk_config so the "me" handler # defined in the env file is automatically registered. -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( options=ApplicationOptions( @@ -337,7 +337,7 @@ def create_app() -> web.Application: app["agent_app"] = AGENT_APP app["adapter"] = ADAPTER - app["agent_configuration"] = CONNECTION_MANAGER.get_default_connection_configuration() + app["agent_configuration"] = agents_sdk_config app.router.add_get("/", _handle_root) app.router.add_post("/api/messages", _handle_messages) @@ -352,7 +352,7 @@ def create_app() -> web.Application: if __name__ == "__main__": - host = environ.get("HOST", "localhost") - port = int(environ.get("PORT", "3978")) + host = agents_sdk_config.get_str("HOST", default="localhost") + port = agents_sdk_config.get_int("PORT", default=3978) web.run_app(create_app(), host=host, port=port) diff --git a/test_samples/teams_extension/meeting_events_agent.py b/test_samples/teams_extension/meeting_events_agent.py index 1cd5e827b..66ae8ae22 100644 --- a/test_samples/teams_extension/meeting_events_agent.py +++ b/test_samples/teams_extension/meeting_events_agent.py @@ -43,14 +43,13 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, authorization=AUTHORIZATION, - **agents_sdk_config, ) TEAMS = TeamsAgentExtension[TurnState](AGENT_APP) @@ -110,5 +109,5 @@ async def on_read_receipt( start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/teams_extension/message_extensions_agent.py b/test_samples/teams_extension/message_extensions_agent.py index 7328f7a67..7900c9a94 100644 --- a/test_samples/teams_extension/message_extensions_agent.py +++ b/test_samples/teams_extension/message_extensions_agent.py @@ -45,14 +45,13 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, authorization=AUTHORIZATION, - **agents_sdk_config, ) TEAMS = TeamsAgentExtension[TurnState](AGENT_APP) @@ -289,5 +288,5 @@ async def on_fetch_task( start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/teams_extension/shared/start_server.py b/test_samples/teams_extension/shared/start_server.py index 8c245a5db..a99d9aa83 100644 --- a/test_samples/teams_extension/shared/start_server.py +++ b/test_samples/teams_extension/shared/start_server.py @@ -1,5 +1,5 @@ -from os import environ -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration +from microsoft_agents.hosting.core import AgentApplication from microsoft_agents.hosting.aiohttp import ( start_agent_process, jwt_authorization_middleware, @@ -11,7 +11,7 @@ def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration: AgentAuthConfiguration + config: Configuration ): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -21,12 +21,12 @@ async def entry_point(req: Request) -> Response: APP = Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", entry_point) APP.router.add_get("/api/messages", lambda _: Response(status=200)) - APP["agent_configuration"] = auth_configuration + APP["agent_configuration"] = config APP["agent_app"] = agent_application APP["adapter"] = adapter run_app( APP, - host=environ.get("HOST", "localhost"), - port=int(environ.get("PORT", "3978")), + host=config.get_str("HOST", default="localhost"), + port=config.get_int("PORT", default=3978), ) diff --git a/test_samples/teams_extension/task_modules_agent.py b/test_samples/teams_extension/task_modules_agent.py index 007496923..81342f8c9 100644 --- a/test_samples/teams_extension/task_modules_agent.py +++ b/test_samples/teams_extension/task_modules_agent.py @@ -34,17 +34,16 @@ load_dotenv(path.join(path.dirname(__file__), ".env")) agents_sdk_config = load_configuration_from_env(environ) -APP_BASE_URL = environ.get("APP_BASE_URL", "http://localhost:3978") +APP_BASE_URL = agents_sdk_config.get_str("APP_BASE_URL", default="http://localhost:3978") STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( storage=STORAGE, authorization=AUTHORIZATION, - **agents_sdk_config, ) TEAMS = TeamsAgentExtension[TurnState](AGENT_APP) @@ -276,5 +275,5 @@ async def on_multi_step_submit_email( start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/testing/src/app.py b/test_samples/testing/src/app.py index c5719865f..17ec2103f 100644 --- a/test_samples/testing/src/app.py +++ b/test_samples/testing/src/app.py @@ -20,13 +20,14 @@ load_dotenv() CONFIGURATION = load_configuration_from_env(environ) -CONNECTION_MANAGER = MsalConnectionManager(**CONFIGURATION) +CONNECTION_MANAGER = MsalConnectionManager(config=CONFIGURATION) AGENT_APP = AgentApplication[TurnState]( options=ApplicationOptions( storage=MemoryStorage(), start_typing_timer=False, ), connection_manager=CONNECTION_MANAGER, + config=CONFIGURATION, ) @@ -54,5 +55,5 @@ async def echo_message(context: TurnContext, _state: TurnState) -> None: start_server( AGENT_APP, cloud_adapter, - CONNECTION_MANAGER.get_default_connection_configuration(), + CONFIGURATION, ) diff --git a/test_samples/testing/src/start_server.py b/test_samples/testing/src/start_server.py index a553a1887..cf36d4f0a 100644 --- a/test_samples/testing/src/start_server.py +++ b/test_samples/testing/src/start_server.py @@ -3,22 +3,21 @@ """aiohttp hosting for the testing sample.""" -from os import environ - from aiohttp.web import Application, Request, Response, run_app +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.aiohttp import ( CloudAdapter, jwt_authorization_middleware, start_agent_process, ) -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.hosting.core import AgentApplication def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration: AgentAuthConfiguration, + config: Configuration, ) -> None: """Host an AgentApplication at the standard messages endpoint.""" @@ -33,8 +32,8 @@ async def health_check(_request: Request) -> Response: web_app = Application(middlewares=[jwt_authorization_middleware]) web_app.router.add_post("/api/messages", process_messages) web_app.router.add_get("/api/messages", health_check) - web_app["agent_configuration"] = auth_configuration + web_app["agent_configuration"] = config web_app["agent_application"] = agent_application web_app["adapter"] = adapter - run_app(web_app, host="localhost", port=int(environ.get("PORT", "3978"))) + run_app(web_app, host="localhost", port=config.get_int("PORT", default=3978)) diff --git a/test_samples/tethered-agent/src/agent.py b/test_samples/tethered-agent/src/agent.py index 5e5ee60d9..5cfa29f12 100644 --- a/test_samples/tethered-agent/src/agent.py +++ b/test_samples/tethered-agent/src/agent.py @@ -23,13 +23,13 @@ agents_sdk_config = load_configuration_from_env(environ) STORAGE = MemoryStorage() -CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) -AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config + storage=STORAGE, authorization=AUTHORIZATION ) diff --git a/test_samples/tethered-agent/src/main.py b/test_samples/tethered-agent/src/main.py index 44a0c45af..51a7b9740 100644 --- a/test_samples/tethered-agent/src/main.py +++ b/test_samples/tethered-agent/src/main.py @@ -7,11 +7,11 @@ "www.botframework.", ]) -from .agent import AGENT_APP, CONNECTION_MANAGER, ADAPTER +from .agent import AGENT_APP, ADAPTER, agents_sdk_config from .start_server import start_server start_server( agent_application=AGENT_APP, adapter=ADAPTER, - auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + config=agents_sdk_config, ) diff --git a/test_samples/tethered-agent/src/start_server.py b/test_samples/tethered-agent/src/start_server.py index 1f2e3544b..c0e965561 100644 --- a/test_samples/tethered-agent/src/start_server.py +++ b/test_samples/tethered-agent/src/start_server.py @@ -1,5 +1,5 @@ -from os import environ -from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration +from microsoft_agents.activity.config import Configuration +from microsoft_agents.hosting.core import AgentApplication from microsoft_agents.hosting.aiohttp import ( start_agent_process, jwt_authorization_middleware, @@ -11,7 +11,7 @@ def start_server( agent_application: AgentApplication, adapter: CloudAdapter, - auth_configuration: AgentAuthConfiguration + config: Configuration ): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] @@ -24,11 +24,11 @@ async def entry_point(req: Request) -> Response: APP = Application(middlewares=[jwt_authorization_middleware]) APP.router.add_post("/api/messages", entry_point) - APP["agent_configuration"] = auth_configuration + APP["agent_configuration"] = config APP["agent_app"] = agent_application APP["adapter"] = adapter try: - run_app(APP, host="localhost", port=environ.get("PORT", 3978)) + run_app(APP, host="localhost", port=config.get_int("PORT", default=3978)) except Exception as error: raise error diff --git a/tests/activity/config/test_configuration.py b/tests/activity/config/test_configuration.py index 50523edce..4db69e954 100644 --- a/tests/activity/config/test_configuration.py +++ b/tests/activity/config/test_configuration.py @@ -8,6 +8,22 @@ from microsoft_agents.activity.config import Configuration, path, xpath +@pytest.mark.parametrize( + "value, expected", + [ + ("scope1 scope2", ["scope1", "scope2"]), + ("scope1, scope2", ["scope1", "scope2"]), + ("scope1,scope2 scope3", ["scope1", "scope2", "scope3"]), + (" , \t ", []), + (None, []), + ], +) +def test_scope_strings_use_shared_whitespace_and_comma_normalization(value, expected): + config = Configuration(scopes=value) + + assert config.get_scopes("scopes") == expected + + def test_section_uses_matched_alias_and_nested_path(): config = Configuration( {"Connections": {"Service": {"SETTINGS": {"Enabled": True}}}} diff --git a/tests/activity/config/test_load_configuration.py b/tests/activity/config/test_load_configuration.py index 1e7425dc7..d61d44efb 100644 --- a/tests/activity/config/test_load_configuration.py +++ b/tests/activity/config/test_load_configuration.py @@ -52,8 +52,6 @@ {"CONNECTION": "SERVICE_CONNECTION", "SERVICEURL": "*"}, {"CONNECTION": "AGENTIC", "SERVICEURL": "agentic"}, ], - "LOGGING": {}, - "TOKENVALIDATION": {}, } ENV_RAW = """ @@ -118,10 +116,30 @@ def test_load_configuration_from_read_only_mapping(): def test_load_configuration_from_empty_mapping(): config = load_configuration_from_env({}) - assert config == { - "AGENTAPPLICATION": {}, - "CONNECTIONS": {}, - "CONNECTIONSMAP": [], - "LOGGING": {}, - "TOKENVALIDATION": {}, + assert config == {} + + +def test_environment_values_override_defaults_and_preserve_custom_sections(): + defaults = { + "CONNECTIONS": { + "SERVICE_CONNECTION": { + "SETTINGS": {"CLIENTID": "default-client", "TENANTID": "tenant"} + } + }, + "CUSTOM": {"ENABLED": "false"}, + } + + config = load_configuration_from_env( + {"CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID": "env-client"}, + defaults=defaults, + ) + + assert config["CONNECTIONS"]["SERVICE_CONNECTION"]["SETTINGS"] == { + "CLIENTID": "env-client", + "TENANTID": "tenant", } + assert config["CUSTOM"] == {"ENABLED": "false"} + assert ( + defaults["CONNECTIONS"]["SERVICE_CONNECTION"]["SETTINGS"]["CLIENTID"] + == "default-client" + ) diff --git a/tests/authentication_entra_auth_sidecar/test_settings.py b/tests/authentication_entra_auth_sidecar/test_settings.py index 6e4ddd666..516de3736 100644 --- a/tests/authentication_entra_auth_sidecar/test_settings.py +++ b/tests/authentication_entra_auth_sidecar/test_settings.py @@ -4,8 +4,8 @@ from microsoft_agents.activity import load_configuration_from_env from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core import AgentAuthConfiguration, ConnectionManager -from microsoft_agents.authentication.entra_auth_sidecar import SidecarAuth -from microsoft_agents.authentication.entra_auth_sidecar._models import ( +from microsoft_agents.authentication.entra_auth_sidecar import ( + SidecarAuth, SidecarConnectionSettings, ) from microsoft_agents.authentication.entra_auth_sidecar.errors import ( @@ -15,32 +15,27 @@ import pytest -class TestScopeNormalization: - def test_none(self): - assert SidecarConnectionSettings._normalize_scopes(None) is None - - def test_list(self): - assert SidecarConnectionSettings._normalize_scopes(["a", "b"]) == ["a", "b"] - - def test_space_delimited_string(self): - assert SidecarConnectionSettings._normalize_scopes("a b") == ["a", "b"] - - def test_comma_delimited_string(self): - assert SidecarConnectionSettings._normalize_scopes("a, b") == ["a", "b"] - - def test_dict_from_env_nesting(self): - # Env "SCOPES__0=a", "SCOPES__1=b" parses into {"0": "a", "1": "b"}. - assert SidecarConnectionSettings._normalize_scopes({"0": "a", "1": "b"}) == [ - "a", - "b", - ] +class TestSettingsFromConfiguration: + @pytest.mark.parametrize("nested", [False, True]) + def test_mapping_binding_and_typed_rebinding_preserve_settings(self, nested): + values = { + "CLIENTID": "blueprint", + "SCOPES": "scope1,scope2 scope3", + "RETRY_COUNT": "4", + "BYPASS_LOCAL_NETWORK_RESTRICTION": "false", + } + source = {"SETTINGS": values} if nested else values - def test_empty_collapses_to_none(self): - assert SidecarConnectionSettings._normalize_scopes("") is None - assert SidecarConnectionSettings._normalize_scopes([]) is None + settings = SidecarConnectionSettings.from_mapping(source) + assert settings.client_id == "blueprint" + assert settings.scopes == ["scope1", "scope2", "scope3"] + assert settings.retry_count == 4 + assert settings.bypass_local_network_restriction is False + assert SidecarConnectionSettings.from_configuration(settings) is settings + assert values["SCOPES"] == "scope1,scope2 scope3" + assert values["RETRY_COUNT"] == "4" -class TestSettingsFromConfiguration: def test_configuration_manager_binds_typed_sidecar_settings(self): manager = ConnectionManager( SidecarAuth, @@ -182,33 +177,41 @@ class TestScalarCoercion: """Scalar settings must tolerate the string values produced by env config.""" def test_bypass_false_string_keeps_protection_on(self): - settings = SidecarConnectionSettings(bypass_local_network_restriction="false") + settings = SidecarConnectionSettings.from_mapping( + bypass_local_network_restriction="false" + ) assert settings.bypass_local_network_restriction is False def test_bypass_true_string(self): - settings = SidecarConnectionSettings(bypass_local_network_restriction="true") + settings = SidecarConnectionSettings.from_mapping( + bypass_local_network_restriction="true" + ) assert settings.bypass_local_network_restriction is True @pytest.mark.parametrize("raw", ["0", "false", "False", "FALSE"]) def test_bypass_falsy_strings(self, raw): - settings = SidecarConnectionSettings(bypass_local_network_restriction=raw) + settings = SidecarConnectionSettings.from_mapping( + bypass_local_network_restriction=raw + ) assert settings.bypass_local_network_restriction is False @pytest.mark.parametrize("raw", ["1", "true", "True", "TRUE"]) def test_bypass_truthy_strings(self, raw): - settings = SidecarConnectionSettings(bypass_local_network_restriction=raw) + settings = SidecarConnectionSettings.from_mapping( + bypass_local_network_restriction=raw + ) assert settings.bypass_local_network_restriction is True - @pytest.mark.parametrize("raw", ["maybe", "no", "off", "yes", "on", ""]) - def test_unrecognized_or_unset_bypass_value_fails_safe_to_false(self, raw): - # The call site supplies default=False, so unrecognized/unset values - # (including the dropped yes/on/no/off spellings) fail safe to False. - settings = SidecarConnectionSettings(bypass_local_network_restriction=raw) + @pytest.mark.parametrize("raw", [None, "", " "]) + def test_unset_bypass_value_defaults_to_false(self, raw): + settings = SidecarConnectionSettings.from_mapping( + bypass_local_network_restriction=raw + ) assert settings.bypass_local_network_restriction is False - @pytest.mark.parametrize("raw", ["maybe", "no", "off", "yes", "on", ""]) + @pytest.mark.parametrize("raw", ["maybe", "no", "off", "yes", "on"]) @pytest.mark.parametrize("config_type", [AgentAuthConfiguration, Configuration]) - def test_unrecognized_bypass_value_from_configuration_keeps_protection( + def test_unrecognized_bypass_value_from_configuration_is_rejected( self, raw, config_type ): config = config_type( @@ -217,33 +220,35 @@ def test_unrecognized_bypass_value_from_configuration_keeps_protection( SIDECAR_BASE_URL="http://example.com", BYPASS_LOCAL_NETWORK_RESTRICTION=raw, ) - settings = SidecarConnectionSettings.from_configuration(config) - assert settings.bypass_local_network_restriction is False - with pytest.raises(SidecarAuthError): + with pytest.raises(ValueError, match="BYPASS_LOCAL_NETWORK_RESTRICTION"): + SidecarConnectionSettings.from_configuration(config) + with pytest.raises(ValueError, match="BYPASS_LOCAL_NETWORK_RESTRICTION"): SidecarAuth(config) def test_retry_count_string_coerced_to_int(self): - settings = SidecarConnectionSettings(retry_count="5") + settings = SidecarConnectionSettings.from_mapping(retry_count="5") assert settings.retry_count == 5 assert isinstance(settings.retry_count, int) def test_request_timeout_string_coerced_to_float(self): - settings = SidecarConnectionSettings(request_timeout="45") + settings = SidecarConnectionSettings.from_mapping(request_timeout="45") assert settings.request_timeout == 45.0 assert isinstance(settings.request_timeout, float) def test_blank_numeric_strings_fall_back_to_defaults(self): - settings = SidecarConnectionSettings(retry_count="", request_timeout="") + settings = SidecarConnectionSettings.from_mapping( + retry_count="", request_timeout="" + ) assert settings.retry_count == 3 assert settings.request_timeout == 30.0 def test_invalid_retry_count_raises_clear_error(self): with pytest.raises(ValueError, match="RETRY_COUNT"): - SidecarConnectionSettings(retry_count="abc") + SidecarConnectionSettings.from_mapping(RETRY_COUNT="abc") def test_invalid_request_timeout_raises_clear_error(self): with pytest.raises(ValueError, match="REQUEST_TIMEOUT"): - SidecarConnectionSettings(request_timeout="abc") + SidecarConnectionSettings.from_mapping(REQUEST_TIMEOUT="abc") class TestEnvScalarFlowEndToEnd: diff --git a/tests/authentication_msal/test_connection_settings.py b/tests/authentication_msal/test_connection_settings.py index 84ca22ad1..a74cb1d9b 100644 --- a/tests/authentication_msal/test_connection_settings.py +++ b/tests/authentication_msal/test_connection_settings.py @@ -3,7 +3,7 @@ import pytest -from microsoft_agents.activity.config import Configuration +from microsoft_agents.activity.config import Configuration, load_configuration_from_env from microsoft_agents.authentication.msal import ( MsalAuth, MsalConnectionManager, @@ -54,6 +54,99 @@ def test_msal_settings_bind_aliases_and_round_trip_legacy_fields_without_mutatio assert restored.provider_settings["custom_option"] == "value" assert settings.extras == before assert "_connections" not in settings.extras + assert "_configured_issuers" not in settings.extras + + +@pytest.mark.parametrize( + "auth_type, credential", + [ + (AuthTypes.certificate, {"cert_pfx_file": "certificate.pfx"}), + (AuthTypes.federated_credentials, {"federated_client_id": "identity"}), + (AuthTypes.workload_identity, {"federated_token_file": "token-file"}), + ], +) +def test_settings_bind_required_credentials_from_configuration(auth_type, credential): + settings = MsalConnectionSettings.from_mapping(auth_type=auth_type, **credential) + auth = MsalAuth(settings) + + assert auth.config.auth_type == auth_type + assert getattr(auth.config, next(iter(credential))) == next( + iter(credential.values()) + ) + + +@pytest.mark.parametrize( + "values, field, expected", + [ + ({"SENDX5C": "false"}, "send_x5c", False), + ({"SENDX5C": "true"}, "send_x5c", True), + ({"AZUREREGION": "eastus"}, "azure_region", "eastus"), + ({"REGIONALAUTHORITY": "westus"}, "azure_region", "westus"), + ( + {"AZUREREGION": "eastus", "REGIONALAUTHORITY": "westus"}, + "azure_region", + "eastus", + ), + ( + {"AUTHORITY": "https://primary", "AUTHORITYENDPOINT": "https://secondary"}, + "authority", + "https://primary", + ), + ({"AUTHORITYENDPOINT": "https://authority"}, "authority", "https://authority"), + ( + {"ALT_BLUEPRINT_NAME": "legacy", "ALTERNATEBLUEPRINTCONNECTIONNAME": "new"}, + "alternate_blueprint_connection_name", + "legacy", + ), + ( + {"IDPMRESOURCE": "https://resource/.default"}, + "idpm_resource", + "https://resource/.default", + ), + ], +) +def test_settings_bind_legacy_aliases_and_precedence(values, field, expected): + settings = MsalConnectionSettings.from_mapping(values) + + assert getattr(settings, field) == expected + + +def test_msal_settings_bind_environment_fields_without_exposing_credentials_in_extras(): + config = load_configuration_from_env( + { + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID": "client", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID": "tenant", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET": "test-secret", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SENDX5C": "false", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES__2": "scope2", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__SCOPES__0": "scope1", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CUSTOM_OPTION": "custom", + } + ) + manager = MsalConnectionManager(config=config) + settings = manager.get_default_connection().config + + assert (settings.client_id, settings.tenant_id, settings.client_secret) == ( + "client", + "tenant", + "test-secret", + ) + assert settings.send_x5c is False + assert settings.scopes == ["scope1", "scope2"] + assert settings.extras["CUSTOM_OPTION"] == "custom" + assert "CLIENTSECRET" not in settings.extras + assert "test-secret" not in repr(settings) + + +def test_legacy_msal_provider_and_compatibility_view_emit_deprecations(): + with pytest.warns(DeprecationWarning, match="AgentAuthConfiguration is deprecated"): + legacy = AgentAuthConfiguration(client_id="client") + with pytest.warns(DeprecationWarning, match="AgentAuthConfiguration is deprecated"): + auth = MsalAuth(legacy) + with pytest.warns( + DeprecationWarning, match="Accessing the deprecated configuration" + ): + assert auth.configuration is legacy @pytest.mark.parametrize("bound", [False, True]) diff --git a/tests/authentication_msal/test_msal_auth.py b/tests/authentication_msal/test_msal_auth.py index d68e6b25b..703eafc41 100644 --- a/tests/authentication_msal/test_msal_auth.py +++ b/tests/authentication_msal/test_msal_auth.py @@ -1,7 +1,7 @@ import pytest from msal import ManagedIdentityClient, ConfidentialClientApplication -from microsoft_agents.authentication.msal import MsalAuth +from microsoft_agents.authentication.msal import MsalAuth, MsalConnectionSettings from microsoft_agents.hosting.core import Connections from microsoft_agents.hosting.core.authorization import ( AgentAuthConfiguration, @@ -242,15 +242,15 @@ class TestMsalAuthAzureRegion: """ def test_resolve_azure_region_when_configured(self): - config = AgentAuthConfiguration(azure_region="westus") + config = MsalConnectionSettings(azure_region="westus") assert MsalAuth._resolve_azure_region(config) == "westus" def test_resolve_azure_region_none_when_unset(self): - config = AgentAuthConfiguration() + config = MsalConnectionSettings() assert MsalAuth._resolve_azure_region(config) is None def test_resolve_azure_region_none_when_whitespace(self): - config = AgentAuthConfiguration(azure_region=" ") + config = MsalConnectionSettings(azure_region=" ") assert MsalAuth._resolve_azure_region(config) is None def test_create_client_application_passes_azure_region(self, mocker): @@ -363,7 +363,7 @@ class TestMsalAuthIdentityProxyManager: """ def test_resolve_idpm_resource_defaults_when_unset(self): - config = AgentAuthConfiguration( + config = MsalConnectionSettings( auth_type=AuthTypes.identity_proxy_manager, client_id="test-client-id", ) @@ -373,7 +373,7 @@ def test_resolve_idpm_resource_defaults_when_unset(self): ) def test_resolve_idpm_resource_uses_custom_resource(self): - config = AgentAuthConfiguration( + config = MsalConnectionSettings( auth_type=AuthTypes.identity_proxy_manager, client_id="test-client-id", idpm_resource="https://custom-resource/.default", @@ -384,7 +384,7 @@ def test_resolve_idpm_resource_uses_custom_resource(self): ) def test_resolve_idpm_resource_raises_on_invalid_uri(self): - config = AgentAuthConfiguration( + config = MsalConnectionSettings( auth_type=AuthTypes.identity_proxy_manager, client_id="test-client-id", idpm_resource="not-a-valid-uri", diff --git a/tests/copilotstudio_client/test_console_sample.py b/tests/copilotstudio_client/test_console_sample.py index 5107f2450..43855e007 100644 --- a/tests/copilotstudio_client/test_console_sample.py +++ b/tests/copilotstudio_client/test_console_sample.py @@ -6,6 +6,7 @@ import pytest +from microsoft_agents.activity.config import Configuration from microsoft_agents.copilotstudio.client import PowerPlatformCloud @@ -34,11 +35,15 @@ def test_sample_uses_configured_scope_and_interactive_fallback( sample, mocker, cloud, cached ): settings = sample.McsConnectionSettings( - app_client_id="client", - tenant_id="tenant", - environment_id="environment", - agent_identifier="agent", - cloud=cloud, + Configuration( + { + "APP_CLIENT_ID": "client", + "TENANT_ID": "tenant", + "ENVIRONMENT_ID": "environment", + "AGENT_IDENTIFIER": "agent", + "CLOUD": cloud.name, + } + ) ) mocker.patch.object(sample, "get_msal_token_cache") application = mocker.patch.object(sample, "PublicClientApplication").return_value @@ -66,11 +71,15 @@ def test_sample_uses_configured_scope_and_interactive_fallback( def test_sample_signs_in_interactively_without_cached_accounts(sample, mocker): settings = sample.McsConnectionSettings( - app_client_id="client", - tenant_id="tenant", - environment_id="environment", - agent_identifier="agent", - cloud=PowerPlatformCloud.TEST, + Configuration( + { + "APP_CLIENT_ID": "client", + "TENANT_ID": "tenant", + "ENVIRONMENT_ID": "environment", + "AGENT_IDENTIFIER": "agent", + "CLOUD": "TEST", + } + ) ) mocker.patch.object(sample, "get_msal_token_cache") application = mocker.patch.object(sample, "PublicClientApplication").return_value diff --git a/tests/hosting_core/app/_oauth/test_auth_handler.py b/tests/hosting_core/app/_oauth/test_auth_handler.py index b362891e4..4f4235edf 100644 --- a/tests/hosting_core/app/_oauth/test_auth_handler.py +++ b/tests/hosting_core/app/_oauth/test_auth_handler.py @@ -1,6 +1,7 @@ import pytest from microsoft_agents.hosting.core import AuthHandler +from microsoft_agents.activity import load_configuration_from_env from tests._common.data import ( DEFAULT_TEST_VALUES, @@ -14,6 +15,29 @@ class TestAuthHandler: + @pytest.mark.parametrize("from_settings", [False, True]) + def test_indexed_environment_scopes_are_bound_without_mutation(self, from_settings): + config = load_configuration_from_env( + { + "AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__graph__SETTINGS__SCOPES__2": "Mail.Read", + "AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__graph__SETTINGS__SCOPES__0": "User.Read", + } + ) + settings = config["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"]["graph"][ + "SETTINGS" + ] + scopes = settings["SCOPES"] + + handler = ( + AuthHandler._from_settings(settings) + if from_settings + else AuthHandler("graph", **settings) + ) + + assert handler.scopes == ["User.Read", "Mail.Read"] + assert settings["SCOPES"] is scopes + assert scopes == ["User.Read", "Mail.Read"] + @pytest.fixture def auth_setting(self): return ENV_DICT["AGENTAPPLICATION"]["USERAUTHORIZATION"]["HANDLERS"][ diff --git a/tests/hosting_core/authorization/test_connections_map.py b/tests/hosting_core/authorization/test_connections_map.py index 773968cc1..0b909fa4d 100644 --- a/tests/hosting_core/authorization/test_connections_map.py +++ b/tests/hosting_core/authorization/test_connections_map.py @@ -15,7 +15,7 @@ def test_empty_maps_disable_routing(items): @pytest.mark.parametrize("items", ["invalid", [None], {"rule": "invalid"}]) def test_invalid_map_shapes_raise(items): - with pytest.raises(TypeError, match="mapping"): + with pytest.raises(ValueError, match="mapping"): _ConnectionMap(items) diff --git a/tests/hosting_core/authorization/test_jwt_connection_settings.py b/tests/hosting_core/authorization/test_jwt_connection_settings.py index 19fe984b3..6ef86449e 100644 --- a/tests/hosting_core/authorization/test_jwt_connection_settings.py +++ b/tests/hosting_core/authorization/test_jwt_connection_settings.py @@ -4,10 +4,32 @@ import pytest from microsoft_agents.activity.config import Configuration, _resolve -from microsoft_agents.hosting.core.authorization.connection_settings_base import ( +from microsoft_agents.hosting.core.authorization.connections import ( ConnectionSettingsBase, ) from microsoft_agents.hosting.core.authorization.jwt import JwtConnectionSettings +from microsoft_agents.hosting.core import AgentAuthConfiguration + + +def test_legacy_binding_preserves_explicit_issuers_without_internal_provider_extras(): + legacy = AgentAuthConfiguration( + client_id="client", + issuers=["https://custom.example/"], + validate_issuer=True, + anonymous_allowed=True, + custom_option="value", + ) + original_connections = legacy._connections + settings = JwtConnectionSettings._from_agent_auth_config(legacy) + + assert settings.issuers == ["https://custom.example/"] + assert settings.validate_issuer is True + assert settings.anonymous_allowed is True + assert settings.extras["custom_option"] == "value" + assert "_connections" not in settings.extras + assert "_configured_issuers" not in settings.extras + assert legacy._connections is original_connections + assert legacy.ISSUERS == ["https://custom.example/"] @pytest.mark.parametrize( diff --git a/tests/hosting_core/authorization/test_token_validation_configuration.py b/tests/hosting_core/authorization/test_token_validation_configuration.py index 657a30099..f90cbc465 100644 --- a/tests/hosting_core/authorization/test_token_validation_configuration.py +++ b/tests/hosting_core/authorization/test_token_validation_configuration.py @@ -140,7 +140,7 @@ def test_legacy_environment_scopes_bind_without_mutating_configuration( 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 + assert raw_scopes == expected @pytest.mark.asyncio @@ -239,28 +239,11 @@ def test_no_service_connection_has_no_default_route_or_inherited_anonymous_acces "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): diff --git a/tests/hosting_core/test_connection_manager.py b/tests/hosting_core/test_connection_manager.py index 600efccd9..1b2f5a19b 100644 --- a/tests/hosting_core/test_connection_manager.py +++ b/tests/hosting_core/test_connection_manager.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import warnings + import pytest from azure.core.credentials import AccessToken from azure.core.credentials_async import AsyncTokenCredential @@ -13,6 +15,7 @@ ClaimsIdentity, ConnectionManager, ConnectionSettingsBase, + JwtTokenValidator, ) from microsoft_agents.hosting.core.authorization._helpers import ( _CallableTokenCredential, @@ -140,6 +143,90 @@ def test_legacy_provider_config_fallback_preserves_base_fields(): assert settings.scopes == ["scope"] +@pytest.mark.parametrize("nested", [False, True]) +@pytest.mark.parametrize("use_kwargs", [False, True]) +def test_modern_factory_receives_settings_with_the_original_diagnostic_path( + nested, use_kwargs +): + values = {"client_id": "client", "scopes": ["scope"]} + section = {"settings": values} if nested else values + root = {"connections": {"SERVICE_CONNECTION": section}} + manager = ( + ConnectionManager(ConfiguredProvider, **root) + if use_kwargs + else ConnectionManager(ConfiguredProvider, config=Configuration(root)) + ) + provider = manager.get_default_connection() + + assert provider.config.client_id == "client" + assert provider.config.scopes == ["scope"] + assert provider.received_config._base_path == ( + "connections::SERVICE_CONNECTION::settings" + if nested + else "connections::SERVICE_CONNECTION" + ) + assert root["connections"]["SERVICE_CONNECTION"] == section + + +@pytest.mark.parametrize("explicit_instances", [False, True]) +def test_legacy_manager_preserves_all_connection_audiences(explicit_instances): + configs = { + "SERVICE_CONNECTION": AgentAuthConfiguration(client_id="service"), + "OTHER": AgentAuthConfiguration(client_id="other"), + } + if explicit_instances: + manager = ConnectionManager(FakeProvider, connections_configurations=configs) + else: + manager = ConnectionManager( + FakeProvider, + connections_map=[], + CONNECTIONS={ + name: {"SETTINGS": {"CLIENTID": config.CLIENT_ID}} + for name, config in configs.items() + }, + ) + legacy = manager.get_default_connection_configuration() + policy = JwtTokenValidator(legacy).config + + assert policy.audiences == ["service", "other"] + assert set(policy.connections) == {"SERVICE_CONNECTION", "OTHER"} + assert ( + manager.get_connection("OTHER").configuration._connections + is legacy._connections + ) + assert manager.get_default_connection().configuration is legacy + + +def test_legacy_manager_entry_points_emit_deprecation_warnings(): + with pytest.warns(DeprecationWarning, match="AgentAuthConfiguration is deprecated"): + legacy = AgentAuthConfiguration(client_id="client") + with pytest.warns(DeprecationWarning, match="connections_configurations"): + manager = ConnectionManager( + FakeProvider, connections_configurations={"SERVICE_CONNECTION": legacy} + ) + with pytest.warns( + DeprecationWarning, + match=r"ConnectionManager\.get_default_connection_configuration", + ): + assert manager.get_default_connection_configuration() is legacy + + +def test_modern_configuration_binding_does_not_use_deprecated_apis(): + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always", DeprecationWarning) + config = Configuration( + connections={"SERVICE_CONNECTION": {"settings": {"client_id": "client"}}} + ) + manager = ConnectionManager(ConfiguredProvider, config=config) + policy = JwtTokenValidator(config).config + + assert manager.get_default_connection().config.client_id == "client" + assert policy.audiences == ["client"] + assert not [ + item for item in captured if issubclass(item.category, DeprecationWarning) + ] + + @pytest.mark.asyncio async def test_fake_provider_get_token_credential_is_synchronous(): provider = FakeProvider(AgentAuthConfiguration()) @@ -188,7 +275,9 @@ async def test_fake_provider_get_token_credential_is_synchronous(): class TestGenericConnectionManager: def _make(self, **kwargs): - return ConnectionManager(provider_factory=FakeProvider, **kwargs) + return ConnectionManager( + provider_factory=FakeProvider, connections_configurations={}, **kwargs + ) def _activity( self, diff --git a/tests/hosting_core/test_connection_settings_base.py b/tests/hosting_core/test_connection_settings_base.py index c792e1c46..5555a8304 100644 --- a/tests/hosting_core/test_connection_settings_base.py +++ b/tests/hosting_core/test_connection_settings_base.py @@ -3,9 +3,7 @@ from microsoft_agents.hosting.core import ( AgentAuthConfiguration, -) -from microsoft_agents.hosting.core.authorization.connection_settings_base import ( - _ConnectionSettingsBase, + ConnectionSettingsBase, ) @@ -13,15 +11,15 @@ class TestConnectionSettingsBase: """Validate the provider-agnostic base settings and its binding helpers.""" def test_defaults_are_none(self): - settings = _ConnectionSettingsBase() + settings = ConnectionSettingsBase() assert settings.client_id is None assert settings.authority is None assert settings.tenant_id is None - assert settings.scopes is None + assert settings.scopes == [] assert settings.alternate_blueprint_connection_name is None def test_explicit_values(self): - settings = _ConnectionSettingsBase( + settings = ConnectionSettingsBase( client_id="cid", authority="https://authority", tenant_id="tid", @@ -42,23 +40,23 @@ def test_from_configuration_binds_base_fields(self): scopes=["s1"], ALT_BLUEPRINT_NAME="bp", ) - settings = _ConnectionSettingsBase.from_configuration(config) + settings = ConnectionSettingsBase._from_agent_auth_config(config) assert settings.client_id == "cid" assert settings.tenant_id == "tid" assert settings.authority == "https://authority" assert settings.scopes == ["s1"] assert settings.alternate_blueprint_connection_name == "bp" - def test_base_kwargs_from_configuration_reads_dotnet_aliases(self): - config = AgentAuthConfiguration( + def test_from_mapping_reads_dotnet_aliases(self): + config = dict( CLIENTID="cid", AUTHORITYENDPOINT="https://authority", ALTERNATEBLUEPRINTCONNECTIONNAME="bp", ) - kwargs = _ConnectionSettingsBase.base_kwargs_from_configuration(config) - assert kwargs["client_id"] == "cid" - assert kwargs["authority"] == "https://authority" - assert kwargs["alternate_blueprint_connection_name"] == "bp" + settings = ConnectionSettingsBase.from_mapping(config) + assert settings.client_id == "cid" + assert settings.authority == "https://authority" + assert settings.alternate_blueprint_connection_name == "bp" def test_from_configuration_binds_from_mapping(self): # A raw mapping (e.g. a parsed SETTINGS dict) must bind the same way an @@ -70,7 +68,7 @@ def test_from_configuration_binds_from_mapping(self): "SCOPES": ["s1"], "ALT_BLUEPRINT_ID": "bp", } - settings = _ConnectionSettingsBase.from_configuration(config) + settings = ConnectionSettingsBase.from_mapping(config) assert settings.client_id == "cid" assert settings.authority == "https://authority" assert settings.tenant_id == "tid" @@ -88,7 +86,7 @@ def test_from_configuration_binds_from_env_style_mapping(self): "SCOPES": ["s1"], "ALTERNATEBLUEPRINTCONNECTIONNAME": "bp", } - settings = _ConnectionSettingsBase.from_configuration(config) + settings = ConnectionSettingsBase.from_mapping(config) assert settings.client_id == "cid" assert settings.authority == "https://authority" assert settings.tenant_id == "tid" diff --git a/tests/hosting_core/test_log_config.py b/tests/hosting_core/test_log_config.py index ed5f52054..e0109f4de 100644 --- a/tests/hosting_core/test_log_config.py +++ b/tests/hosting_core/test_log_config.py @@ -3,8 +3,8 @@ import pytest -from microsoft_agents.hosting.core import AgentAuthConfiguration from microsoft_agents.activity.config import Configuration +from microsoft_agents.hosting.core.authorization.connections import _ConnectionMap from microsoft_agents.hosting.core.authorization._log_config import ( _log_config, _redact_scopes, @@ -98,13 +98,14 @@ def test_configuration_binding_redacts_credentials_and_provider_extras(self): def test_summarize_auth_configs_formats_connections_as_json_array(self): summary = _summarize_auth_configs( { - "SERVICE_CONNECTION": AgentAuthConfiguration( + "SERVICE_CONNECTION": Configuration( client_id="client-id-secret", tenant_id="tenant-id-secret", client_secret="client-secret", connection_name="configured-name", authority="https://login.microsoftonline.com/tenant/oauth2/v2.0/token", scopes=["scope1", "scope2"], + anonymous_allowed=False, ) } ) @@ -128,14 +129,16 @@ def test_summarize_auth_configs_formats_connections_as_json_array(self): def test_summarize_connections_map_redacts_service_urls(self): summary = _summarize_connections_map( - [ - { - "CONNECTION": "SERVICE_CONNECTION", - "AUDIENCE": "api://service", - "SERVICEURL": "https://service.example.com/path?secret=value", - }, - {"CONNECTION": "AGENTIC", "SERVICEURL": "*"}, - ] + _ConnectionMap( + [ + { + "CONNECTION": "SERVICE_CONNECTION", + "AUDIENCE": "api://service", + "SERVICEURL": "https://service.example.com/path?secret=value", + }, + {"CONNECTION": "AGENTIC", "SERVICEURL": "*"}, + ] + ) ) parsed_summary = json.loads(summary) @@ -152,10 +155,10 @@ def test_summarize_connections_map_redacts_service_urls(self): def test_log_config_uses_clean_parameterized_format(self): logger = Mock() - config_map = { - "SERVICE_CONNECTION": AgentAuthConfiguration(client_id="client-id-secret") - } - connections_map = [{"CONNECTION": "SERVICE_CONNECTION", "SERVICEURL": "*"}] + config_map = {"SERVICE_CONNECTION": Configuration(client_id="client-id-secret")} + connections_map = _ConnectionMap( + [{"CONNECTION": "SERVICE_CONNECTION", "SERVICEURL": "*"}] + ) _log_config(logger, config_map, connections_map) diff --git a/tests/hosting_fastapi/test_jwt_authorization_middleware.py b/tests/hosting_fastapi/test_jwt_authorization_middleware.py index 834b4efe3..747b922dd 100644 --- a/tests/hosting_fastapi/test_jwt_authorization_middleware.py +++ b/tests/hosting_fastapi/test_jwt_authorization_middleware.py @@ -158,14 +158,17 @@ async def test_fastapi_decorator_stores_claims_and_calls_handler(): async def route(request: Request): return {"aud": request.state.claims_identity.claims["aud"]} + scope = _scope(auth_config, "Bearer token") 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"))) + response = await route(Request(scope)) assert response == {"aud": "decorator-app"} - 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 @@ -176,6 +179,7 @@ async def test_fastapi_decorator_converts_http_response(): async def route(request: Request): return {"called": True} + scope = _scope(auth_config) with patch( "microsoft_agents.hosting.fastapi.jwt_authorization_middleware._authorize_request", new=AsyncMock( @@ -185,11 +189,11 @@ async def route(request: Request): ) ), ) as authorize: - response = await route(Request(_scope(auth_config))) + response = await route(Request(scope)) assert response.status_code == 401 assert response.body == b'{"error":"Authorization header not found"}' - authorize.assert_awaited_once_with(None, auth_config) + authorize.assert_awaited_once_with(None, auth_config, cache_key=scope["app"]) def test_fastapi_decorator_preserves_route_signature(): From 5a729d3a617d32feae13bb1048b5d4834e42fd8b Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 4 Nov 2026 11:03:51 -0800 Subject: [PATCH 12/15] Updating samples --- test_samples/agentic-test/src/agent.py | 4 +- test_samples/agentic-test/src/start_server.py | 4 +- test_samples/app_style/authorization_agent.py | 4 +- test_samples/app_style/empty_agent.py | 4 +- test_samples/app_style/mcs_agent.py | 8 +- test_samples/app_style/shared/start_server.py | 4 +- test_samples/app_style/streaming_agent.py | 4 +- .../compat/dialogs/complex_dialog/src/main.py | 1 - .../compat/dialogs/custom_dialogs/src/main.py | 1 - .../compat/dialogs/multi_turn/src/main.py | 2 +- .../compat/dialogs/oauth_prompt/src/main.py | 2 +- .../compat/dialogs/user_auth/src/main.py | 2 +- .../copilot_studio_client_sample/config.py | 1 + test_samples/entra_sidecar/README.md | 3 +- test_samples/entra_sidecar/agent.py | 6 +- .../extension-starter/src/sample/app.py | 4 +- test_samples/fastapi/authorization_agent.py | 4 +- test_samples/fastapi/empty_agent.py | 4 +- .../handling_attachments/src/agent.py | 2 +- test_samples/handling_attachments/src/main.py | 1 - .../hosting_msteams/task-modules/src/agent.py | 4 +- test_samples/otel/quickstart/src/agent.py | 4 +- .../otel/quickstart/src/start_server.py | 4 +- test_samples/otel/zero-code/src/agent.py | 4 +- .../otel/zero-code/src/start_server.py | 4 +- .../teams_extension/shared/start_server.py | 4 +- .../teams_extension/task_modules_agent.py | 4 +- test_samples/tethered-agent/src/agent.py | 4 +- .../tethered-agent/src/start_server.py | 4 +- .../test_console_sample.py | 50 ++++++++ .../test_sample_configuration.py | 109 ++++++++++++++++++ 31 files changed, 193 insertions(+), 67 deletions(-) create mode 100644 tests/hosting_aiohttp/test_sample_configuration.py diff --git a/test_samples/agentic-test/src/agent.py b/test_samples/agentic-test/src/agent.py index 2d1914b8b..0c045c256 100644 --- a/test_samples/agentic-test/src/agent.py +++ b/test_samples/agentic-test/src/agent.py @@ -36,9 +36,7 @@ ADAPTER.use(TranscriptLoggerMiddleware(ConsoleTranscriptLogger())) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.activity("message", auth_handlers=["AGENTIC"]) diff --git a/test_samples/agentic-test/src/start_server.py b/test_samples/agentic-test/src/start_server.py index c0e965561..5d3ce9a03 100644 --- a/test_samples/agentic-test/src/start_server.py +++ b/test_samples/agentic-test/src/start_server.py @@ -9,9 +9,7 @@ def start_server( - agent_application: AgentApplication, - adapter: CloudAdapter, - config: Configuration + agent_application: AgentApplication, adapter: CloudAdapter, config: Configuration ): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] diff --git a/test_samples/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index c3f877865..0957f96b4 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -30,9 +30,7 @@ ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.message(re.compile(r"^(status|auth status|check status)", re.IGNORECASE)) diff --git a/test_samples/app_style/empty_agent.py b/test_samples/app_style/empty_agent.py index b1fdcfa31..60e9f65ea 100644 --- a/test_samples/app_style/empty_agent.py +++ b/test_samples/app_style/empty_agent.py @@ -34,9 +34,7 @@ AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) async def _help(context: TurnContext, _state: TurnState): diff --git a/test_samples/app_style/mcs_agent.py b/test_samples/app_style/mcs_agent.py index 7ee5a60b6..4a89f1151 100644 --- a/test_samples/app_style/mcs_agent.py +++ b/test_samples/app_style/mcs_agent.py @@ -69,9 +69,7 @@ def __init__(self, config: Configuration) -> None: # Create the agent instance -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.conversation_update("membersAdded") @@ -165,7 +163,9 @@ async def _status(context: TurnContext, state: TurnState) -> None: def _create_client(token: str) -> CopilotClient: """Create CopilotClient with connection settings from environment""" - settings = McsConnectionSettings(agents_sdk_config.get_section("COPILOTSTUDIOAGENT")) + settings = McsConnectionSettings( + agents_sdk_config.get_section("COPILOTSTUDIOAGENT") + ) return CopilotClient(settings, token) diff --git a/test_samples/app_style/shared/start_server.py b/test_samples/app_style/shared/start_server.py index 64164b1ac..e08c69b7a 100644 --- a/test_samples/app_style/shared/start_server.py +++ b/test_samples/app_style/shared/start_server.py @@ -9,9 +9,7 @@ def start_server( - agent_application: AgentApplication, - adapter: CloudAdapter, - config: Configuration + agent_application: AgentApplication, adapter: CloudAdapter, config: Configuration ): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] diff --git a/test_samples/app_style/streaming_agent.py b/test_samples/app_style/streaming_agent.py index 4a9d0a5ad..f67862816 100644 --- a/test_samples/app_style/streaming_agent.py +++ b/test_samples/app_style/streaming_agent.py @@ -35,9 +35,7 @@ ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.activity(ActivityTypes.invoke) diff --git a/test_samples/compat/dialogs/complex_dialog/src/main.py b/test_samples/compat/dialogs/complex_dialog/src/main.py index 0a2ec39dc..b2079ab5f 100644 --- a/test_samples/compat/dialogs/complex_dialog/src/main.py +++ b/test_samples/compat/dialogs/complex_dialog/src/main.py @@ -44,4 +44,3 @@ async def messages(req: Request) -> Response: web.run_app(APP, host="localhost", port=3978) except Exception: raise - \ No newline at end of file diff --git a/test_samples/compat/dialogs/custom_dialogs/src/main.py b/test_samples/compat/dialogs/custom_dialogs/src/main.py index feb60ecfa..5ca183975 100644 --- a/test_samples/compat/dialogs/custom_dialogs/src/main.py +++ b/test_samples/compat/dialogs/custom_dialogs/src/main.py @@ -44,4 +44,3 @@ async def messages(req: Request) -> Response: web.run_app(APP, host="localhost", port=3978) except Exception as error: raise error - \ No newline at end of file diff --git a/test_samples/compat/dialogs/multi_turn/src/main.py b/test_samples/compat/dialogs/multi_turn/src/main.py index f97db241a..b2333d0bf 100644 --- a/test_samples/compat/dialogs/multi_turn/src/main.py +++ b/test_samples/compat/dialogs/multi_turn/src/main.py @@ -43,4 +43,4 @@ async def messages(req: Request) -> Response: try: web.run_app(APP, host="localhost", port=3978) except Exception as error: - raise error \ No newline at end of file + raise error diff --git a/test_samples/compat/dialogs/oauth_prompt/src/main.py b/test_samples/compat/dialogs/oauth_prompt/src/main.py index 491abfdd7..314c02a28 100644 --- a/test_samples/compat/dialogs/oauth_prompt/src/main.py +++ b/test_samples/compat/dialogs/oauth_prompt/src/main.py @@ -47,4 +47,4 @@ async def messages(req: Request) -> Response: try: web.run_app(APP, host="localhost", port=3978) except Exception as error: - raise error \ No newline at end of file + raise error diff --git a/test_samples/compat/dialogs/user_auth/src/main.py b/test_samples/compat/dialogs/user_auth/src/main.py index 9ea304367..2c5a072b0 100644 --- a/test_samples/compat/dialogs/user_auth/src/main.py +++ b/test_samples/compat/dialogs/user_auth/src/main.py @@ -47,4 +47,4 @@ async def messages(req: Request) -> Response: try: web.run_app(APP, host="localhost", port=3978) except Exception as error: - raise error \ No newline at end of file + raise error diff --git a/test_samples/copilot_studio_client_sample/config.py b/test_samples/copilot_studio_client_sample/config.py index c45e84b69..8a501e1da 100644 --- a/test_samples/copilot_studio_client_sample/config.py +++ b/test_samples/copilot_studio_client_sample/config.py @@ -7,6 +7,7 @@ class McsConnectionSettings(ConnectionSettings): + def __init__(self, config: Configuration) -> None: self.app_client_id = config.get_str("APP_CLIENT_ID", default=None) self.tenant_id = config.get_str("TENANT_ID", default=None) diff --git a/test_samples/entra_sidecar/README.md b/test_samples/entra_sidecar/README.md index 8e7f6c44a..ec9033399 100644 --- a/test_samples/entra_sidecar/README.md +++ b/test_samples/entra_sidecar/README.md @@ -13,7 +13,7 @@ The sample uses the generic `ConnectionManager` from `microsoft-agents-hosting-c from microsoft_agents.hosting.core import ConnectionManager from microsoft_agents.authentication.entra_auth_sidecar import SidecarAuth -CONNECTION_MANAGER = ConnectionManager(provider_factory=SidecarAuth, **agents_sdk_config) +CONNECTION_MANAGER = ConnectionManager(provider_factory=SidecarAuth, config=agents_sdk_config) ``` No `SidecarConnectionManager` is needed — the generic connection manager dispatches to any @@ -85,4 +85,3 @@ because `TENANTID` is missing/placeholder, or the inbound token's issuer require > Note: credential-free applies to **outbound** token acquisition (the sidecar owns those > secrets). Validating **inbound** channel tokens is a separate concern controlled by > `TOKENVALIDATION__ENABLED`. - diff --git a/test_samples/entra_sidecar/agent.py b/test_samples/entra_sidecar/agent.py index fc6b8b6ab..bcd70e0b3 100644 --- a/test_samples/entra_sidecar/agent.py +++ b/test_samples/entra_sidecar/agent.py @@ -43,7 +43,7 @@ # sidecar without a real tenant/app registration. Set TOKENVALIDATION__ENABLED=true # (and a real CLIENTID/TENANTID) for any non-local deployment. TOKEN_VALIDATION_ENABLED = agents_sdk_config.get_bool( - ["TOKENVALIDATION", "ENABLED"], default=False + "TOKENVALIDATION::ENABLED", default=False ) STORAGE = MemoryStorage() @@ -54,9 +54,7 @@ ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) async def _help(context: TurnContext, _state: TurnState): diff --git a/test_samples/extensions/extension-starter/src/sample/app.py b/test_samples/extensions/extension-starter/src/sample/app.py index 097b36df9..43cd2a686 100644 --- a/test_samples/extensions/extension-starter/src/sample/app.py +++ b/test_samples/extensions/extension-starter/src/sample/app.py @@ -26,9 +26,7 @@ CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @APP.activity("message", rank=RouteRank.LAST) diff --git a/test_samples/fastapi/authorization_agent.py b/test_samples/fastapi/authorization_agent.py index bdb82d003..09a4237a0 100644 --- a/test_samples/fastapi/authorization_agent.py +++ b/test_samples/fastapi/authorization_agent.py @@ -44,9 +44,7 @@ ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.message(re.compile(r"^/(status|auth status|check status)", re.IGNORECASE)) diff --git a/test_samples/fastapi/empty_agent.py b/test_samples/fastapi/empty_agent.py index eadf478cd..09f62ede5 100644 --- a/test_samples/fastapi/empty_agent.py +++ b/test_samples/fastapi/empty_agent.py @@ -33,9 +33,7 @@ ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) # Create FastAPI app app = FastAPI(title="Empty Agent Sample", version="1.0.0") diff --git a/test_samples/handling_attachments/src/agent.py b/test_samples/handling_attachments/src/agent.py index 76ea3635a..b78fd9d7f 100644 --- a/test_samples/handling_attachments/src/agent.py +++ b/test_samples/handling_attachments/src/agent.py @@ -194,4 +194,4 @@ def get_internet_attachment() -> Attachment: name="resources\\introducing-agents-sdk.png", content_type="image/png", content_url="https://devblogs.microsoft.com/microsoft365dev/wp-content/uploads/sites/73/2024/11/word-image-23435-1.png" - ) \ No newline at end of file + ) diff --git a/test_samples/handling_attachments/src/main.py b/test_samples/handling_attachments/src/main.py index 7090079c8..80c64490c 100644 --- a/test_samples/handling_attachments/src/main.py +++ b/test_samples/handling_attachments/src/main.py @@ -8,7 +8,6 @@ from .agent import AGENT_APP, ADAPTER, agents_sdk_config - if __name__ == "__main__": app = FastAPI(title="Handling Attachments Agent", version="1.0.0") diff --git a/test_samples/hosting_msteams/task-modules/src/agent.py b/test_samples/hosting_msteams/task-modules/src/agent.py index 7bc70810e..edb4c4297 100644 --- a/test_samples/hosting_msteams/task-modules/src/agent.py +++ b/test_samples/hosting_msteams/task-modules/src/agent.py @@ -41,7 +41,9 @@ agents_sdk_config = load_configuration_from_env(environ) # Public base URL of this agent (dev tunnel / host) used for the webpage dialog. -APP_BASE_URL = agents_sdk_config.get_str("APP_BASE_URL", default="http://localhost:3978") +APP_BASE_URL = agents_sdk_config.get_str( + "APP_BASE_URL", default="http://localhost:3978" +) STORAGE = MemoryStorage() CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) diff --git a/test_samples/otel/quickstart/src/agent.py b/test_samples/otel/quickstart/src/agent.py index b5a34e5e8..53a0eeb3a 100644 --- a/test_samples/otel/quickstart/src/agent.py +++ b/test_samples/otel/quickstart/src/agent.py @@ -31,9 +31,7 @@ AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.conversation_update("membersAdded") diff --git a/test_samples/otel/quickstart/src/start_server.py b/test_samples/otel/quickstart/src/start_server.py index 6d8954c36..fd04f84fd 100644 --- a/test_samples/otel/quickstart/src/start_server.py +++ b/test_samples/otel/quickstart/src/start_server.py @@ -13,9 +13,7 @@ def start_server( - agent_application: AgentApplication, - adapter: CloudAdapter, - config: Configuration + agent_application: AgentApplication, adapter: CloudAdapter, config: Configuration ): async def entry_point(req: Request) -> Response: diff --git a/test_samples/otel/zero-code/src/agent.py b/test_samples/otel/zero-code/src/agent.py index 07537d163..7b67c948a 100644 --- a/test_samples/otel/zero-code/src/agent.py +++ b/test_samples/otel/zero-code/src/agent.py @@ -31,9 +31,7 @@ ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.conversation_update("membersAdded") diff --git a/test_samples/otel/zero-code/src/start_server.py b/test_samples/otel/zero-code/src/start_server.py index 3712c9896..c6e835893 100644 --- a/test_samples/otel/zero-code/src/start_server.py +++ b/test_samples/otel/zero-code/src/start_server.py @@ -13,9 +13,7 @@ def start_server( - agent_application: AgentApplication, - adapter: CloudAdapter, - config: Configuration + agent_application: AgentApplication, adapter: CloudAdapter, config: Configuration ): async def entry_point(req: Request) -> Response: logger.info("Request received at /api/messages endpoint.") diff --git a/test_samples/teams_extension/shared/start_server.py b/test_samples/teams_extension/shared/start_server.py index a99d9aa83..8d2de5103 100644 --- a/test_samples/teams_extension/shared/start_server.py +++ b/test_samples/teams_extension/shared/start_server.py @@ -9,9 +9,7 @@ def start_server( - agent_application: AgentApplication, - adapter: CloudAdapter, - config: Configuration + agent_application: AgentApplication, adapter: CloudAdapter, config: Configuration ): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] diff --git a/test_samples/teams_extension/task_modules_agent.py b/test_samples/teams_extension/task_modules_agent.py index 81342f8c9..556d09476 100644 --- a/test_samples/teams_extension/task_modules_agent.py +++ b/test_samples/teams_extension/task_modules_agent.py @@ -34,7 +34,9 @@ load_dotenv(path.join(path.dirname(__file__), ".env")) agents_sdk_config = load_configuration_from_env(environ) -APP_BASE_URL = agents_sdk_config.get_str("APP_BASE_URL", default="http://localhost:3978") +APP_BASE_URL = agents_sdk_config.get_str( + "APP_BASE_URL", default="http://localhost:3978" +) STORAGE = MemoryStorage() CONNECTION_MANAGER = MsalConnectionManager(config=agents_sdk_config) diff --git a/test_samples/tethered-agent/src/agent.py b/test_samples/tethered-agent/src/agent.py index 5cfa29f12..da066e0e4 100644 --- a/test_samples/tethered-agent/src/agent.py +++ b/test_samples/tethered-agent/src/agent.py @@ -28,9 +28,7 @@ AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) -AGENT_APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.conversation_update("membersAdded") diff --git a/test_samples/tethered-agent/src/start_server.py b/test_samples/tethered-agent/src/start_server.py index c0e965561..5d3ce9a03 100644 --- a/test_samples/tethered-agent/src/start_server.py +++ b/test_samples/tethered-agent/src/start_server.py @@ -9,9 +9,7 @@ def start_server( - agent_application: AgentApplication, - adapter: CloudAdapter, - config: Configuration + agent_application: AgentApplication, adapter: CloudAdapter, config: Configuration ): async def entry_point(req: Request) -> Response: agent: AgentApplication = req.app["agent_app"] diff --git a/tests/copilotstudio_client/test_console_sample.py b/tests/copilotstudio_client/test_console_sample.py index 43855e007..f4cb5d127 100644 --- a/tests/copilotstudio_client/test_console_sample.py +++ b/tests/copilotstudio_client/test_console_sample.py @@ -90,3 +90,53 @@ def test_sample_signs_in_interactively_without_cached_accounts(sample, mocker): application.acquire_token_interactive.assert_called_once_with( scopes=["https://api.test.powerplatform.com/.default"] ) + + +@pytest.mark.parametrize("enabled", ["true", "false"]) +def test_sample_reads_direct_connection_and_flags_from_configuration( + sample, monkeypatch, enabled +): + monkeypatch.setenv("CLOUD", "PROD") + settings = sample.McsConnectionSettings( + Configuration( + { + "APP_CLIENT_ID": "client", + "TENANT_ID": "tenant", + "CLOUD": "TEST", + "COPILOT_AGENT_TYPE": "PUBLISHED", + "DIRECT_CONNECT_URL": "https://custom.example.com/agent", + "USE_EXPERIMENTAL_ENDPOINT": enabled, + "ENABLE_DIAGNOSTICS": enabled, + } + ) + ) + + assert settings.cloud == PowerPlatformCloud.TEST + assert settings.direct_connect_url == "https://custom.example.com/agent" + assert settings.use_experimental_endpoint is (enabled == "true") + assert settings.enable_diagnostics is (enabled == "true") + + +def test_sample_client_uses_configured_token_cache(sample, mocker): + config = Configuration( + { + "APP_CLIENT_ID": "client", + "TENANT_ID": "tenant", + "ENVIRONMENT_ID": "environment", + "AGENT_IDENTIFIER": "agent", + "TOKEN_CACHE_PATH": "custom-cache.bin", + } + ) + acquire_token = mocker.patch.object( + sample, "acquire_token", return_value="sample-token" + ) + copilot_client = mocker.patch.object(sample, "CopilotClient") + + client = sample.create_mcs_client(config) + + settings = acquire_token.call_args.args[0] + acquire_token.assert_called_once_with(settings, "custom-cache.bin") + assert settings.environment_id == "environment" + assert settings.agent_identifier == "agent" + copilot_client.assert_called_once_with(settings, "sample-token") + assert client is copilot_client.return_value diff --git a/tests/hosting_aiohttp/test_sample_configuration.py b/tests/hosting_aiohttp/test_sample_configuration.py new file mode 100644 index 000000000..d73b29ced --- /dev/null +++ b/tests/hosting_aiohttp/test_sample_configuration.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import importlib.util +import os +import sys +from pathlib import Path + +import pytest + +from microsoft_agents.activity.config import Configuration + +_SAMPLES = Path(__file__).resolve().parents[2] / "test_samples" + + +@pytest.fixture( + params=[ + r"agentic-test\src\start_server.py", + r"app_style\shared\start_server.py", + r"cards\start_server.py", + r"extensions\extension-starter\src\sample\start_server.py", + r"extensions\slack-agent\src\start_server.py", + r"hosting_msteams\conversation-agent\src\start_server.py", + r"hosting_msteams\graph-clients\src\start_server.py", + r"hosting_msteams\message-extensions\src\start_server.py", + r"hosting_msteams\task-modules\src\start_server.py", + r"otel\quickstart\src\start_server.py", + r"otel\zero-code\src\start_server.py", + r"teams_extension\shared\start_server.py", + r"testing\src\start_server.py", + r"tethered-agent\src\start_server.py", + ] +) +def sample_server(request, monkeypatch): + sample_path = _SAMPLES.joinpath(*request.param.split("\\")) + module_name = "_sample_configuration_server" + spec = importlib.util.spec_from_file_location( + module_name, + sample_path, + submodule_search_locations=[str(sample_path.parent)], + ) + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, module_name, module) + spec.loader.exec_module(module) + yield module + for name in list(sys.modules): + if name.startswith(module_name + "."): + del sys.modules[name] + + +@pytest.mark.parametrize("port", [None, "4123"]) +def test_sample_server_passes_root_configuration(sample_server, mocker, port): + config = Configuration( + { + "PORT": port, + "TOKENVALIDATION": {"AUDIENCES": ["sample-client"]}, + "AGENTAPPLICATION": {"USERAUTHORIZATION": {"HANDLERS": {}}}, + } + ) + application = mocker.patch.object(sample_server, "Application").return_value + run_app = mocker.patch.object(sample_server, "run_app") + agent, adapter = object(), object() + + sample_server.start_server(agent, adapter, config=config) + + assigned = { + call.args[0]: call.args[1] for call in application.__setitem__.call_args_list + } + assert assigned["agent_configuration"] is config + assert assigned["adapter"] is adapter + assert agent in assigned.values() + run_app.assert_called_once_with( + application, host="localhost", port=3978 if port is None else 4123 + ) + + +@pytest.mark.parametrize("enabled", [None, "true", "false"]) +def test_sidecar_sample_reads_nested_token_validation(mocker, enabled): + settings = { + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID": "sample-client", + "CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID": "sample-tenant", + } + if enabled is not None: + settings["TOKENVALIDATION__ENABLED"] = enabled + mocker.patch.dict(os.environ, settings, clear=True) + mocker.patch("dotenv.load_dotenv", return_value=False) + spec = importlib.util.spec_from_file_location( + "_sidecar_sample_configuration", _SAMPLES / "entra_sidecar" / "agent.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + application = mocker.patch.object(module, "Application") + mocker.patch.object(module, "run_app") + + module.start_server(module.AGENT_APP, module.ADAPTER) + + assert module.TOKEN_VALIDATION_ENABLED is (enabled == "true") + application.assert_called_once_with( + middlewares=[ + ( + module.jwt_authorization_middleware + if enabled == "true" + else module._anonymous_auth_middleware + ) + ] + ) + application.return_value.__setitem__.assert_any_call( + "agent_configuration", module.agents_sdk_config + ) From 3d8100fc7d874cb1ea45afbba511dab72963f494 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 4 Nov 2026 11:06:02 -0800 Subject: [PATCH 13/15] Fixing tests issue --- tests/hosting_core/test_connection_manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/hosting_core/test_connection_manager.py b/tests/hosting_core/test_connection_manager.py index 1b2f5a19b..269bc7ed8 100644 --- a/tests/hosting_core/test_connection_manager.py +++ b/tests/hosting_core/test_connection_manager.py @@ -117,7 +117,6 @@ def test_configuration_manager_rejects_mixed_constructor_modes(legacy): @pytest.mark.parametrize( "config, path", [ - ({"connections": []}, "connections"), ( {"connections": {"SERVICE_CONNECTION": "invalid"}}, "connections::SERVICE_CONNECTION", From a8a3ad65ddc937504b66b3b8de78b5b789592df6 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 4 Nov 2026 11:21:58 -0800 Subject: [PATCH 14/15] Another commit --- .../entra_auth_sidecar/connection_settings.py | 9 ++++++++- .../authentication/msal/msal_auth.py | 16 ++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py index 382291711..421f3a729 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py @@ -24,7 +24,7 @@ ) -def _or_default(s: str, default: str) -> str: +def _or_default(s: str | None, default: str) -> str: return (s.strip() if s else None) or default @@ -50,6 +50,13 @@ class SidecarConnectionSettings(ConnectionSettingsBase): request_timeout: float = DEFAULT_REQUEST_TIMEOUT_SECONDS retry_count: int = DEFAULT_RETRY_COUNT + def __post_init__(self) -> None: + object.__setattr__( + self, + "bypass_local_network_restriction", + bool(self.bypass_local_network_restriction), + ) + @classmethod def _get_init_kwargs(cls, config: Configuration) -> dict: base = super()._get_init_kwargs(config) diff --git a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py index 9cd65af10..1fdb2afce 100644 --- a/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py +++ b/libraries/microsoft-agents-authentication-msal/microsoft_agents/authentication/msal/msal_auth.py @@ -52,21 +52,25 @@ async def _async_acquire_token_for_client(msal_auth_client, *args, **kwargs): class MsalAuth(AccessTokenProviderBase): def __init__( - self, config: Configuration | MsalConnectionSettings | AgentAuthConfiguration + self, + msal_configuration: ( + Configuration | MsalConnectionSettings | AgentAuthConfiguration + ), ): """Initializes the MsalAuth class with the given configuration. - :param config: Raw Configuration or bound MsalConnectionSettings. + :param msal_configuration: Raw Configuration or bound MsalConnectionSettings. AgentAuthConfiguration remains accepted for deprecated compatibility. Do not mutate settings or their nested values after passing them in. :type config: Configuration | MsalConnectionSettings | AgentAuthConfiguration """ + config = msal_configuration if isinstance(config, MsalConnectionSettings): self._deprecated_config = None - msal_configuration = config + _msal_configuration = config elif isinstance(config, Configuration): self._deprecated_config = None - msal_configuration = MsalConnectionSettings.from_configuration(config) + _msal_configuration = MsalConnectionSettings.from_configuration(config) else: warnings.warn( "AgentAuthConfiguration is deprecated and will be removed in future versions. " @@ -75,9 +79,9 @@ def __init__( stacklevel=2, ) self._deprecated_config = config - msal_configuration = MsalConnectionSettings._from_agent_auth_config(config) + _msal_configuration = MsalConnectionSettings._from_agent_auth_config(config) - self._msal_configuration = msal_configuration + self._msal_configuration = _msal_configuration self._msal_auth_client_map: dict[ str, ConfidentialClientApplication | ManagedIdentityClient ] = {} From 7371257fd2796a8e36d0443a877f61dc40fe7722 Mon Sep 17 00:00:00 2001 From: Rodrigo Brandao Date: Wed, 4 Nov 2026 11:36:00 -0800 Subject: [PATCH 15/15] Addressing PR feedback --- .../authentication/entra_auth_sidecar/connection_settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py index 421f3a729..1768e370c 100644 --- a/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py @@ -9,6 +9,7 @@ from microsoft_agents.activity.config import ( Configuration, + coerce_bool, xpath, ) from microsoft_agents.hosting.core.authorization.connections import ( @@ -54,7 +55,7 @@ def __post_init__(self) -> None: object.__setattr__( self, "bypass_local_network_restriction", - bool(self.bypass_local_network_restriction), + coerce_bool(self.bypass_local_network_restriction), ) @classmethod