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. --- diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/config/_load_configuration.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/config/_load_configuration.py index 2221d441c..8ce24f1fc 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/config/_load_configuration.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/config/_load_configuration.py @@ -3,43 +3,28 @@ from typing import Any, Mapping +from .configuration import Configuration + from ._logging import _configure_logging +from ._utils import _xpath from ._resolve import _resolve -def load_configuration_from_env(env_vars: Mapping[str, Any]) -> dict: +def load_configuration_from_env( + env_vars: Mapping[str, Any], defaults: Mapping[str, Any] | None = None +) -> Configuration: """ - Parses environment variables and returns a dictionary with the relevant configuration. + Parses environment variables and returns a Configuration object with the relevant configuration. + + :param env_vars: A mapping of environment variable names to their values. + :param defaults: A mapping of default configuration values. + :return: A Configuration object containing the resolved configuration. """ local_vars = dict(env_vars) - result = {} - for key, value in local_vars.items(): - levels = key.split("__") - current_level = result - last_level = None - for next_level in levels: - if next_level not in current_level: - current_level[next_level] = {} - last_level = current_level - current_level = current_level[next_level] - last_level[levels[-1]] = value - - if result.get("CONNECTIONSMAP") and isinstance(result["CONNECTIONSMAP"], dict): - result["CONNECTIONSMAP"] = [ - conn for conn in result.get("CONNECTIONSMAP", {}).values() - ] - _configure_logging(result.get("LOGGING", {})) - - # temporary fix before next PR - token_validation_options = _resolve( - result.get("TOKENVALIDATION", {}), + resolved = _resolve(local_vars, defaults or {}) + config = Configuration(resolved) + _configure_logging( + config.get_section(_xpath("logging"), default=None) or Configuration() ) - - return { - "AGENTAPPLICATION": result.pop("AGENTAPPLICATION", {}), - "CONNECTIONS": result.pop("CONNECTIONS", {}), - "CONNECTIONSMAP": result.pop("CONNECTIONSMAP", []), - "LOGGING": result.pop("LOGGING", {}), - "TOKENVALIDATION": token_validation_options, - } + return config 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/__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..51607acc8 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,150 +3,6 @@ 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, -) - -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, - ) - class SidecarRequestOptions: """Options used to build the query string for a sidecar token request.""" 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..1768e370c --- /dev/null +++ b/libraries/microsoft-agents-authentication-entra-auth-sidecar/microsoft_agents/authentication/entra_auth_sidecar/connection_settings.py @@ -0,0 +1,116 @@ +# 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, + coerce_bool, + xpath, +) +from microsoft_agents.hosting.core.authorization.connections import ( + AgentAuthConfiguration, + ConnectionSettingsBase, +) +from .constants import ( + DEFAULT_SERVICE_NAME, + DEFAULT_BLUEPRINT_SERVICE_NAME, + DEFAULT_SIDECAR_BASE_URL, + DEFAULT_REQUEST_TIMEOUT_SECONDS, + DEFAULT_RETRY_COUNT, +) + + +def _or_default(s: str | None, default: str) -> str: + return (s.strip() if s else None) or default + + +@dataclass(frozen=True) +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. + """ + + service_name: str | None = DEFAULT_SERVICE_NAME + blueprint_service_name: str | None = 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) -> None: + object.__setattr__( + self, + "bypass_local_network_restriction", + coerce_bool(self.bypass_local_network_restriction), + ) + + @classmethod + def _get_init_kwargs(cls, config: Configuration) -> dict: + base = super()._get_init_kwargs(config) + + # 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. + _service_name = config.get_strn(xpath("service_name")) + service_name = _or_default(_service_name, DEFAULT_SERVICE_NAME) + + blueprint_service_name = config.get_str( + xpath("blueprint_service_name"), default=DEFAULT_BLUEPRINT_SERVICE_NAME + ) + blueprint_service_name = _or_default( + blueprint_service_name, DEFAULT_BLUEPRINT_SERVICE_NAME + ) + + sidecar_base_url = config.get_strn(xpath("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. + bypass_local_network_restriction = config.get_bool( + xpath("bypass_local_network_restriction"), + default=False, + ) + 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, + ) + + base.update( + { + "service_name": service_name, + "blueprint_service_name": blueprint_service_name, + "sidecar_base_url": sidecar_base_url, + "bypass_local_network_restriction": bypass_local_network_restriction, + "request_timeout": request_timeout, + "retry_count": retry_count, + } + ) + return base + + @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) 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..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 @@ -7,6 +7,9 @@ import uuid from datetime import datetime, timedelta, timezone +import warnings + +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core import ( AccessTokenProviderBase, AgentAuthConfiguration, @@ -14,7 +17,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,12 +56,21 @@ class SidecarAuth(AccessTokenProviderBase): def __init__( self, - configuration: AgentAuthConfiguration, + configuration: ( + AgentAuthConfiguration | SidecarConnectionSettings | Configuration + ), *, sidecar_client: SidecarHttpClient | None = None, ): - self._configuration = configuration - self._settings = SidecarConnectionSettings.from_configuration(configuration) + + if isinstance(configuration, AgentAuthConfiguration): + self._configuration = configuration + self._settings = SidecarConnectionSettings._from_agent_auth_config( + configuration + ) + else: + self._configuration = None + self._settings = SidecarConnectionSettings.from_configuration(configuration) if sidecar_client is not None: self._sidecar_client = sidecar_client @@ -90,8 +103,25 @@ def configuration(self) -> AgentAuthConfiguration: such as the alternate-blueprint connection name without depending on a specific provider implementation. """ + + warnings.warn( + "AgentAuthCConfiguration is deprecated and will be removed in a future version." + "This field thus is also deprecated.", + DeprecationWarning, + stacklevel=2, + ) + + if not self._configuration: + 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 +151,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..d9723e2c8 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 @@ -12,9 +12,11 @@ import httpx -from ._models import ( +from .constants import ( DEFAULT_RETRY_COUNT, DEFAULT_SIDECAR_BASE_URL, +) +from ._models import ( SidecarProblemDetails, SidecarRequestOptions, SidecarTokenResult, 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..404710383 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 @@ -8,8 +8,10 @@ from azure.core.credentials import AccessToken from azure.core.credentials_async import AsyncTokenCredential +from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core import AgentAuthConfiguration +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 | SidecarConnectionSettings | Configuration, *, 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 4363f4dff..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 @@ -8,7 +8,9 @@ import logging import time import jwt -from typing import Optional +import warnings + +from typing import Any from urllib.parse import urlparse, ParseResult as URI from msal import ( ConfidentialClientApplication, @@ -23,6 +25,7 @@ from requests import Session +from microsoft_agents.activity.config import Configuration from microsoft_agents.activity._utils import _DeferredString from microsoft_agents.hosting.core import ( @@ -31,7 +34,8 @@ AgentAuthConfiguration, ) from microsoft_agents.hosting.core.authorization.telemetry import spans -from microsoft_agents.authentication.msal.errors import authentication_errors +from .connection_settings import MsalConnectionSettings +from .errors import authentication_errors logger = logging.getLogger(__name__) @@ -47,29 +51,69 @@ 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: ( + Configuration | MsalConnectionSettings | AgentAuthConfiguration + ), + ): """Initializes the MsalAuth class with the given configuration. - :param msal_configuration: The MSAL authentication configuration. Assumed to - not be mutated after being passed in. - :type msal_configuration: :class:`microsoft_agents.hosting.core.authorization.agent_auth_configuration.AgentAuthConfiguration` + :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 + elif isinstance(config, Configuration): + self._deprecated_config = None + _msal_configuration = MsalConnectionSettings.from_configuration(config) + else: + warnings.warn( + "AgentAuthConfiguration is deprecated and will be removed in future versions. " + "Please pass a config of type Mapping instead.", + DeprecationWarning, + stacklevel=2, + ) + self._deprecated_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 ] = {} # TokenCache is thread-safe and async-safe per MSAL documentation self._token_cache = TokenCache() - logger.debug("Initializing MsalAuth") + logger.debug("Initializing MsalAuth provider.") @property def configuration(self) -> AgentAuthConfiguration: - """Returns the MSAL authentication configuration. + """Return the deprecated AgentAuthConfiguration compatibility view. + + Use :attr:`config` for the bound MsalConnectionSettings instead. - :return: The MSAL authentication configuration. - :rtype: :class:`microsoft_agents.hosting.core.authorization.agent_auth_configuration.AgentAuthConfiguration` + :return: The legacy authentication configuration. + :rtype: AgentAuthConfiguration + """ + warnings.warn( + "Accessing the deprecated configuration. This will be removed in future versions.", + DeprecationWarning, + stacklevel=2, + ) + 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 settings; nested mutable values remain mutable. + + :return: The MSAL connection settings. + :rtype: MsalConnectionSettings """ return self._msal_configuration @@ -110,12 +154,10 @@ async def _get_access_token( """ with spans.GetAccessToken( scopes, - self._msal_configuration.AUTH_TYPE, + self._msal_configuration.auth_type, ): logger.debug( - "Requesting access token for resource: %s, scopes: %s", - resource_url, - scopes, + f"Requesting access token for resource: {resource_url}, scopes: {scopes}" ) valid_uri, instance_uri = self._uri_validator(resource_url) if not valid_uri: @@ -191,7 +233,7 @@ async def acquire_token_on_behalf_of( ) if "access_token" not in token: - logger.error("Failed to acquire token on behalf of user.") + logger.error(f"Failed to acquire token on behalf of user.") raise ValueError( authentication_errors.FailedToAcquireToken.format(str(token)) ) @@ -199,8 +241,7 @@ async def acquire_token_on_behalf_of( return token["access_token"] logger.error( - "On-behalf-of flow is not supported with the current authentication type: %s", - msal_auth_client.__class__.__name__, + f"On-behalf-of flow is not supported with the current authentication type: {msal_auth_client.__class__.__name__}" ) raise NotImplementedError( authentication_errors.OnBehalfOfFlowNotSupportedAuthType.format( @@ -210,49 +251,49 @@ async def acquire_token_on_behalf_of( @staticmethod def _resolve_authority( - config: AgentAuthConfiguration, tenant_id: str | None = None + config: MsalConnectionSettings, 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) -> str | None: """Resolves the Azure regional token service (ESTS-R) to use, if configured. - :param config: The agent authentication configuration. - :type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` + :param config: The bound MSAL connection settings. + :type config: :class:`microsoft_agents.authentication.msal.MsalConnectionSettings` :return: The resolved Azure region or None if not configured. :rtype: 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 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) -> 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 not idpm_resource: return "api://AzureAdTokenExchange/.default" @@ -264,35 +305,35 @@ def _resolve_idpm_resource(config: AgentAuthConfiguration) -> str: @staticmethod def _resolve_tenant_id( - config: AgentAuthConfiguration, tenant_id: str | None = None + config: MsalConnectionSettings, 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(), @@ -301,18 +342,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(), ) @@ -323,8 +364,7 @@ def get_assertion() -> str: ) if "access_token" not in result: logger.error( - "Failed to acquire token for federated credentials: %s", - result, + f"Failed to acquire token for federated credentials: {result}" ) raise ValueError( authentication_errors.FailedToAcquireToken.format( @@ -334,13 +374,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: @@ -349,15 +389,14 @@ def get_assertion() -> str: client_credential = {"client_assertion": get_assertion} else: logger.error( - "Unsupported authentication type: %s", - 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), @@ -368,7 +407,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( @@ -388,7 +427,7 @@ def _uri_validator(url_str: str) -> tuple[bool, Optional[URI]]: result = urlparse(url_str) return all([result.scheme, result.netloc]), result except AttributeError: - logger.error("URI parsing error for %s", url_str) + logger.error(f"URI parsing error for {url_str}") return False, None def _resolve_scopes_list(self, instance_url: URI, scopes=None) -> list[str]: @@ -396,7 +435,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(): @@ -404,7 +443,7 @@ def _resolve_scopes_list(self, instance_url: URI, scopes=None) -> list[str]: "{instance}", f"{instance_url.scheme}://{instance_url.hostname}" ) temp_list.append(scope_placeholder) - logger.debug("Resolved scopes: %s", temp_list) + logger.debug(f"Resolved scopes: {temp_list}") return temp_list # the call to MSAL is blocking, but in the future we want to create an asyncio task @@ -446,7 +485,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..05ea90170 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,15 +24,20 @@ def __init__( self, connections_configurations: dict[str, AgentAuthConfiguration] | None = None, connections_map: list[dict[str, str]] | None = None, + *, + config: Configuration | None = None, **kwargs, ): """ Initialize the MSAL connection manager. - :param connections_configurations: A dictionary of connection configurations. + :param connections_configurations: Deprecated legacy connection configurations. :type connections_configurations: dict[str, :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`] - :param connections_map: A list of connection mappings. + :param connections_map: Deprecated list of connection mappings. :type connections_map: list[dict[str, str]] + :param config: Application Configuration containing connections and routing. + Prefer this over the deprecated positional configuration arguments. + :type config: Configuration | None :raises ValueError: If no service connection configuration is provided. """ @@ -39,5 +45,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..2f0e6d18c 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 @@ -8,6 +8,7 @@ from microsoft_agents.hosting.core import AgentAuthConfiguration +from .connection_settings import MsalConnectionSettings from .msal_auth import MsalAuth logger = logging.getLogger(__name__) @@ -28,14 +29,16 @@ class MsalTokenCredential(AsyncTokenCredential): def __init__( self, - config: AgentAuthConfiguration, + config: MsalConnectionSettings | AgentAuthConfiguration, *, provider: MsalAuth | None = None, ): """Initializes the MsalTokenCredential with the given configuration. - :param config: The agent authentication configuration. - :type config: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` + :param config: Bound MSAL settings. AgentAuthConfiguration remains accepted + for deprecated compatibility. + :type config: MsalConnectionSettings | AgentAuthConfiguration + :param provider: An existing provider to reuse instead of constructing one. """ self._config = config self._provider: MsalAuth | None = provider 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..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,15 +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 import ( + AccessTokenProviderBase, + AuthenticationConstants, + AnonymousTokenProvider, + Connections, + ConnectionManager, + AgentAuthConfiguration, + ClaimsIdentity, + JwtTokenValidator, + AuthTypes, + ConnectionSettingsBase, +) # Client API from .client.agent_conversation_reference import AgentConversationReference @@ -114,8 +117,10 @@ # Define the package's public interface __all__ = [ + "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/app/oauth/authorization.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py index 7d0300025..a8b4fcc16 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/authorization.py @@ -17,6 +17,7 @@ TokenResponse, ) from microsoft_agents.activity.activity_types import ActivityTypes +from microsoft_agents.activity.config import Configuration, xpath, _get, _get_str from microsoft_agents.hosting.core.authorization._helpers import ( _CallableTokenCredential, @@ -68,7 +69,9 @@ def __init__( connection_manager: Connections, auth_handlers: dict[str, AuthHandler] | None = None, auto_sign_in: bool = False, + *, use_cache: bool = False, + config: Configuration | None = None, **kwargs, ): """ @@ -100,24 +103,25 @@ def __init__( self._handlers = {} - auth_configuration: dict = kwargs.get("AGENTAPPLICATION", {}).get( - "USERAUTHORIZATION", {} + auth_configuration = Configuration.from_mapping(config, **kwargs).get_section( + xpath("agent_application", "user_authorization"), default=Configuration() ) + if not auth_handlers: # get from config - handlers_config: dict[str, dict] = auth_configuration.get("HANDLERS") or {} + handlers_config = auth_configuration.get(xpath("handlers"), default={}) auth_handlers = { handler_name: AuthHandler( name=handler_name, - auth_type=config.get("TYPE", ""), - **config.get("SETTINGS", {}), + auth_type=_get_str(config, *xpath("type"), default=""), + **_get(config, *xpath("settings"), default={}), ) for handler_name, config in handlers_config.items() } self._handler_settings = auth_handlers - self._auto_sign_in = auto_sign_in or bool( - auth_configuration.get("AUTO_SIGN_IN", False) + self._auto_sign_in = auto_sign_in or auth_configuration.get_bool( + xpath("auto_sign_in"), default=False ) # operations default to the first handler if none specified 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..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,9 +4,12 @@ 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 @@ -21,4 +24,5 @@ "ClaimsIdentity", "JwtTokenValidator", "AuthTypes", + "ConnectionSettingsBase", ] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_entra_issuers.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_entra_issuers.py index f6350f48f..fe8902d75 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_entra_issuers.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_entra_issuers.py @@ -3,10 +3,9 @@ """Shared helpers for recognizing Microsoft Entra ID / Bot Framework token issuers. -Centralizes the cloud (public vs. US Government) and tenant-GUID parsing rules -used by both :class:`AgentAuthConfiguration` (default ``ISSUERS``) and -:class:`JwtTokenValidator` (issuer allow-list validation, tid-to-issuer -binding, and JWKS routing) so the two stay consistent. +Centralizes cloud (public vs. US Government) and tenant-GUID parsing for +JwtTokenValidator's issuer validation, tenant binding, and JWKS routing. +Also provides default issuer construction for legacy configuration compatibility. """ from __future__ import annotations diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py index b9c72105b..c9e10855d 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/_helpers.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import datetime -from typing import Protocol +from typing import Any, Protocol from azure.core.credentials_async import AsyncTokenCredential from azure.core.credentials import AccessToken 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..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 @@ -6,9 +6,10 @@ from logging import Logger from urllib.parse import urlparse +from microsoft_agents.activity.config import Configuration, xpath from microsoft_agents.activity._model_utils import pick_model_dict, SkipNone -from .agent_auth_configuration import AgentAuthConfiguration +from .connections.connections_map import _ConnectionMap _REDACTION_PEEK_LENGTH = 3 _REDACTION_THRESH = _REDACTION_PEEK_LENGTH + 6 @@ -90,53 +91,60 @@ 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, Configuration]) -> str: """ Summarize the authentication configuration for logging. :arg config_map: A dictionary of connection configurations. - :type config_map: dict[str, :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`] + :type config_map: dict[str, :class:`microsoft_agents.activity.config.Configuration`] :return: A string summarizing the authentication configuration. :rtype: str """ + get_strn = lambda key: config.get_strn(xpath(key)) summary = [] for connection_name, config in config_map.items(): 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(get_strn("CONNECTION_NAME")), + CLIENTID=SkipNone( + _redact_str_or_none(get_strn("CLIENT_ID"), peek=True) + ), + TENANTID=SkipNone( + _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( + config.get_strn(xpath("authority"), xpath("authorityendpoint")) + ) + ), + SCOPES=SkipNone(_redact_scopes(config.get_scopes(xpath("SCOPES")))), FEDERATED_CLIENT_ID=SkipNone( - _redact_str_or_none(config.FEDERATED_CLIENT_ID, peek=True) + _redact_str_or_none(get_strn("FEDERATED_CLIENT_ID"), peek=True) ), - CERT_PFX_FILE=SkipNone(_redact_str_or_none(config.CERT_PFX_FILE)), + CERT_PFX_FILE=SkipNone(_redact_str_or_none(get_strn("CERT_PFX_FILE"))), ALT_BLUEPRINT_ID=SkipNone( - _redact_str_or_none(config.ALT_BLUEPRINT_ID, peek=True) + _redact_str_or_none(get_strn("ALT_BLUEPRINT_ID"), peek=True) ), - 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), + IDPM_RESOURCE=SkipNone(_redact_url_or_none(get_strn("IDPM_RESOURCE"))), + AZURE_REGION=SkipNone(_redact_url_or_none(get_strn("AZURE_REGION"))), + ANONYMOUS_ALLOWED=str(config.get_booln(xpath("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: _ConnectionMap) -> str: connections_map_output = [] for mapping in connections_map: - obj = { - "CONNECTION": mapping.get("CONNECTION", ""), - } + obj = {"CONNECTION": mapping.connection} - if "AUDIENCE" in mapping: - obj["AUDIENCE"] = mapping["AUDIENCE"] + if mapping.audience: + obj["AUDIENCE"] = mapping.audience - if "SERVICEURL" in mapping: - service_url = mapping.get("SERVICEURL", "").strip() + if mapping.service_url: + service_url = mapping.service_url.strip() if service_url != "*": service_url = _redact_url(service_url) obj["SERVICEURL"] = service_url @@ -148,18 +156,18 @@ 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, Configuration], + connections_map: _ConnectionMap, ) -> None: """ - Log the configuration of the MSAL connection manager. + Log redacted configuration for a provider-independent connection manager. :arg logger: The logger to use for logging. :type logger: :class:`logging.Logger` :arg connections_map: A list of connection mappings. - :type connections_map: list[dict[str, str]] + :type connections_map: _ConnectionMap :arg config_map: A dictionary of connection configurations. - :type config_map: dict[str, :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`] + :type config_map: dict[str, Configuration] """ connections_output = _summarize_auth_configs(config_map) 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..9b8e438d9 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,26 +1,41 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Protocol +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol 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. + Deprecated legacy configuration view. Prefer :attr:`config`. - :return: The configuration for the access token provider. + :return: AgentAuthConfiguration retained for compatibility. """ + # this property is deprecated and will be removed in future versions raise NotImplementedError() + @property + def config(self) -> ConnectionSettingsBase: + """ + Bound provider-specific settings extending ConnectionSettingsBase. + + :return: The provider's typed connection settings. + """ + # Convert the deprecated configuration to the new connection settings format. + from .connections.connection_settings_base import ConnectionSettingsBase + + return ConnectionSettingsBase.from_mapping(**self.configuration._to_dict()) + @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/anonymous_token_provider.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/anonymous_token_provider.py index 8be0fb2cf..dcc45ebb9 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 @@ -1,13 +1,11 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Optional - from azure.core.credentials import AccessToken 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 @@ -27,6 +25,14 @@ def configuration(self) -> AgentAuthConfiguration: """ return AgentAuthConfiguration() + @property + def config(self) -> ConnectionSettingsBase: + """ + The configuration for the access token provider. + :return: The configuration for the access token provider. + """ + return ConnectionSettingsBase() + async def get_access_token( self, resource_url: str, scopes: list[str], force_refresh: bool = False ) -> str: @@ -45,7 +51,7 @@ async def acquire_token_on_behalf_of( async def get_agentic_application_token( self, tenant_id: str, agent_app_instance_id: str - ) -> Optional[str]: + ) -> str | None: return "" async def get_agentic_instance_token( @@ -59,5 +65,5 @@ async def get_agentic_user_token( agent_app_instance_id: str, agentic_user_id: str, scopes: list[str], - ) -> Optional[str]: + ) -> str | None: return "" 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 deleted file mode 100644 index b98729d0b..000000000 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connection_settings_base.py +++ /dev/null @@ -1,204 +0,0 @@ -# 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, - _resolve, - 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 - if isinstance(attrs.get("SCOPES"), Mapping): - # TODO: Temporary compatibility for legacy indexed scopes; the next - # PR's configuration-loader migration will normalize these mappings. - attrs["SCOPES"] = _resolve({"SCOPES": attrs["SCOPES"]})["SCOPES"] - return cls.from_mapping(attrs) - - @classmethod - 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/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/__init__.py new file mode 100644 index 000000000..aef2fa85d --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/__init__.py @@ -0,0 +1,16 @@ +# 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 +from .connections_map import _ConnectionMap + +__all__ = [ + "AgentAuthConfiguration", + "ConnectionSettingsBase", + "Connections", + "ConnectionManager", + "_ConnectionMap", +] 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/connections/agent_auth_configuration.py similarity index 88% rename from libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/agent_auth_configuration.py rename to libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/agent_auth_configuration.py index ad188d50d..09602c0dc 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/connections/agent_auth_configuration.py @@ -3,9 +3,11 @@ from __future__ import annotations +import warnings + from typing import Any -from microsoft_agents.activity.config._coercion import coerce_bool +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 ( @@ -120,6 +122,10 @@ class AgentAuthConfiguration: # not directly access _connections. _connections: dict[str, AgentAuthConfiguration] + @staticmethod + def from_dict(config: dict[str, Any]) -> AgentAuthConfiguration: + return AgentAuthConfiguration(**config) + def __init__( self, auth_type: AuthTypes | None = None, @@ -142,6 +148,12 @@ def __init__( **kwargs: Any, ): + warnings.warn( + "AgentAuthConfiguration is deprecated and will be removed in a future version. Please use ConnectionSettings instead.", + DeprecationWarning, + stacklevel=2, + ) + 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; @@ -257,6 +269,32 @@ def _validate(self) -> None: "FEDERATED_TOKEN_FILE is required for workload_identity authentication." ) + def _to_dict(self) -> dict[str, Any]: + """ + Converts the agent auth configuration to a dictionary representation. + + :return: A dictionary containing the configuration fields and their values. + """ + return { + "tenant_id": self.TENANT_ID, + "client_id": self.CLIENT_ID, + "authority": self.AUTHORITY, + "scopes": self.SCOPES, + "auth_type": self.AUTH_TYPE, + "cert_pfx_file": self.CERT_PFX_FILE, + "federated_client_id": self.FEDERATED_CLIENT_ID, + "federated_token_file": self.FEDERATED_TOKEN_FILE, + "alt_blueprint_id": self.ALT_BLUEPRINT_ID, + "alternate_blueprint_connection_name": self.ALT_BLUEPRINT_ID, + "idpm_resource": self.IDPM_RESOURCE, + "validate_issuer": self.VALIDATE_ISSUER, + "issuers": self._configured_issuers, + "anonymous_allowed": self.ANONYMOUS_ALLOWED, + "client_secret": self.CLIENT_SECRET, + "azure_region": self.AZURE_REGION, + **self.provider_settings, + } + @property def ISSUERS(self) -> list[str]: """ @@ -300,6 +338,14 @@ def alternate_blueprint_connection_name(self) -> str | None: """Alias for :attr:`ALT_BLUEPRINT_ID` (.NET ``AlternateBlueprintConnectionName``).""" return self.ALT_BLUEPRINT_ID + def _get_audiences(self) -> list[str]: + """Returns a list of all audiences (CLIENT_IDs) for the configured connections.""" + audiences = [] + for conn in self._connections.values(): + if conn.CLIENT_ID: + audiences.append(conn.CLIENT_ID) + return audiences + def _jwt_patch_is_valid_aud(self, aud: Any) -> bool: """ JWT-patch: Checks if the given audience is valid for any of the 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..fcff27f15 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_manager.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import re +import logging +import warnings + +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, path + +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 +from .connections_map import _ConnectionMap + +logger = logging.getLogger(__name__) + +ConfigT = TypeVar("ConfigT", Configuration, AgentAuthConfiguration) + + +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, Configuration] + _deprecated_config_map: dict[str, AgentAuthConfiguration | None] + + def __init__( + self, + provider_factory: ( + Callable[[AgentAuthConfiguration], AccessTokenProviderBase] + | Callable[[Configuration], 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 creating an AccessTokenProviderBase from + Configuration, or AgentAuthConfiguration for legacy connection inputs. + :type provider_factory: Callable[[AgentAuthConfiguration], AccessTokenProviderBase] | Callable[[Configuration], AccessTokenProviderBase] + Note: The factory's signature should match the type of configuration it expects. If using deprecated connection configurations, it should accept an :class:`AgentAuthConfiguration`. Otherwise, it should accept a :class:`Configuration`. + :param connections_configurations: (Deprecated) A dictionary of connection configurations. + :type connections_configurations: dict[str, :class:`microsoft_agents.hosting.core.AgentAuthConfiguration`] + :param connections_map: (Deprecated) A list of connection mappings. + :type connections_map: list[dict[str, str]] + :param config: The configuration for the connection manager. + :type config: :class:`microsoft_agents.activity.config.Configuration` + :raises ValueError: If no service connection configuration is provided. + """ + + self._connections = {} + self._config_map = {} + self._provider_factory = provider_factory + self._deprecated_config_map = {} + + if connections_configurations is not None or connections_map is not None: + warnings.warn( + "The fields 'connections_configurations' and 'connections_map' are deprecated and will be removed in a future version. The corresponding positional arguments will remain until the args' removals, and no other positional arguments after will be added until these fields are removed. Please use `config` instead.", + DeprecationWarning, + 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." + ) + + # ``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 connections_configurations: + for ( + connection_name, + agent_auth_config, + ) in connections_configurations.items(): + self._deprecated_config_map[connection_name] = agent_auth_config + self._config_map[connection_name] = Configuration( + **agent_auth_config.__dict__ + ) + self._connections[connection_name] = provider_factory( + agent_auth_config + ) + else: + raw_configurations: dict[str, dict] = kwargs.get("CONNECTIONS", {}) + 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 + ) + config = Configuration.from_config(config, **kwargs) + + raw_connections_map = config.get(xpath("connections_map"), []) + 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) + + 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 + if item.audience: + audience_match = item.audience.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 deprecated default AgentAuthConfiguration compatibility view. + + Prefer :meth:`get_default_connection_config`. + :return: The default connection configuration for the agent. + :rtype: :class:`microsoft_agents.hosting.core.AgentAuthConfiguration` + """ + warnings.warn( + "ConnectionManager.get_default_connection_configuration() is deprecated; " + "use get_default_connection().config instead.", + DeprecationWarning, + stacklevel=2, + ) + config = self._deprecated_config_map.get("SERVICE_CONNECTION") + if not config: + raise ValueError( + "get_default_connection_configuration() is only available for " + "managers initialized with legacy AgentAuthConfiguration instances. " + "For Configuration-based managers, use get_default_connection().config instead." + ) + 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..aba0ba475 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connection_settings_base.py @@ -0,0 +1,98 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from dataclasses import dataclass, field, fields +from typing import TypeVar +from typing_extensions import Self + +from microsoft_agents.activity.config import Configuration, _Configurable, xpath + +from .agent_auth_configuration import AgentAuthConfiguration + +T = TypeVar("T", bound="ConnectionSettingsBase") + +_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 in _KNOWN_KWARGS_CACHE: + return _KNOWN_KWARGS_CACHE[cls] + + base = [f.name for f in fields(cls) if f.init and f.name != "_extras"] + final = [] + for n in base: + final.extend(xpath(n)) + + _KNOWN_KWARGS_CACHE[cls] = final + return final + + @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 91% 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..48ce3bb29 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): @@ -61,7 +61,7 @@ def get_token_provider_from_activity( @abstractmethod def get_default_connection_configuration(self) -> AgentAuthConfiguration: """ - Get the default connection configuration for the agent. + Get the deprecated default AgentAuthConfiguration compatibility view. :return: The default connection configuration for the agent. """ 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..91058b79a --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/connections/connections_map.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Mapping + +from microsoft_agents.activity.config import ( + Configuration, + _DictConfigurable, + _get_str, + xpath, +) + + +@dataclass(frozen=True) +class _ConnectionMapItem: + + service_url: str = "" + audience: str = "" + connection: str = "" + + +@dataclass(frozen=True) +class _ConnectionMap(_DictConfigurable): + items: list[_ConnectionMapItem] = field(default_factory=list) + + def __init__( + self, + items: list[_ConnectionMapItem] | list[dict] | dict[str, dict] | None = None, + ) -> None: + """Initialize a _ConnectionMap with a list of _ConnectionMapItem, a list of dicts, or a dict of dicts.""" + + final_items: list[_ConnectionMapItem] = [] + + if items is None: + final_items = [] + else: + item_iter = ( + items.items() if isinstance(items, Mapping) else enumerate(items) + ) + for key, val in item_iter: + if isinstance(val, _ConnectionMapItem): + final_items.append(val) + continue + if not isinstance(val, Mapping): + raise ValueError( + f"Expected mapping for key '{key}', got {type(val).__name__}" + ) + + val_config = Configuration(val) + + final_items.append( + _ConnectionMapItem( + service_url=val_config.get_str( + *xpath("service_url"), default="" + ), + audience=val_config.get_str(*xpath("audience"), default=""), + connection=val_config.get_str(*xpath("connection"), default=""), + ) + ) + + object.__setattr__(self, "items", final_items) + + def __getitem__(self, index: str) -> _ConnectionMapItem: + for item in self.items: + if item.connection == index: + return item + raise KeyError(f"No connection map item found for connection '{index}'") + + 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/_authorize_request.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/_authorize_request.py index 3c92c8b29..dd4b0a2cf 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 @@ -10,7 +10,7 @@ from microsoft_agents.activity.config import Configuration from microsoft_agents.hosting.core.http import HttpResponse -from ..agent_auth_configuration import AgentAuthConfiguration +from ..connections import AgentAuthConfiguration from ..claims_identity import ClaimsIdentity from .jwt_token_validator import JwtTokenValidator 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..ce6062a25 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 @@ -6,14 +6,18 @@ from dataclasses import dataclass, field from microsoft_agents.activity.config import Configuration, xpath +from .._entra_issuers import ( + default_connection_issuers, +) -from .._entra_issuers import default_connection_issuers -from ..connection_settings_base import ConnectionSettingsBase +from ..connections import ConnectionSettingsBase @dataclass(frozen=True) class JwtConnectionSettings(ConnectionSettingsBase): - """Provider-independent connection settings for inbound JWT validation.""" + """ + Provider-independent connection settings for inbound JWT validation. + """ validate_issuer: bool = False issuers: list[str] = field(default_factory=list) @@ -22,6 +26,7 @@ class JwtConnectionSettings(ConnectionSettingsBase): @classmethod def _get_init_kwargs(cls, config: Configuration) -> dict: base = super()._get_init_kwargs(config) + 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/jwt_token_validator.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/jwt/jwt_token_validator.py index 720594f32..3de9c92e7 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 @@ -11,7 +11,7 @@ from microsoft_agents.activity.config import Configuration -from ..agent_auth_configuration import AgentAuthConfiguration +from ..connections.agent_auth_configuration import AgentAuthConfiguration from ..authentication_constants import AuthenticationConstants from ..claims_identity import ClaimsIdentity from .._entra_issuers import ( @@ -90,17 +90,13 @@ def __init__(self, configuration: AgentAuthConfiguration | Configuration): 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 - ], + audiences=configuration._get_audiences(), connections={ - name: JwtConnectionSettings._from_agent_auth_config(conn) - for name, conn in configuration._connections.items() + conn_name: JwtConnectionSettings.from_mapping(conn._to_dict()) + for conn_name, conn in configuration._connections.items() }, - default_jwt_settings=JwtConnectionSettings._from_agent_auth_config( - configuration + default_jwt_settings=JwtConnectionSettings.from_mapping( + configuration._to_dict() ), ) else: @@ -145,6 +141,7 @@ async def validate_token(self, token: str) -> ClaimsIdentity: "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.config, 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 943460349..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 @@ -13,6 +13,7 @@ @dataclass(frozen=True) class TokenValidationConfiguration(_Configurable): + tenant_id: str | None = None audiences: list[str] = field(default_factory=list) anonymous_allowed: bool = False @@ -23,6 +24,7 @@ class TokenValidationConfiguration(_Configurable): def _from_configuration( cls, configuration: Configuration ) -> TokenValidationConfiguration: + token_validation = configuration.get_section( xpath("token_validation"), default=Configuration() ) @@ -31,24 +33,24 @@ def _from_configuration( anonymous_allowed = token_validation.get_booln(xpath("anonymous_allowed")) connections: dict[str, JwtConnectionSettings] = {} - connection_config = configuration.get_section( - xpath("connections"), default=Configuration() - ) - for name in connection_config: - connections[name] = JwtConnectionSettings.from_configuration( - connection_config.get_section(name) - ) + 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 update_audiences: + aud = conn.client_id + if aud: + audiences.append(aud) if default_jwt_settings is not None and anonymous_allowed is None: anonymous_allowed = default_jwt_settings.anonymous_allowed @@ -64,7 +66,10 @@ def _from_configuration( 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) + for target_audience in self.audiences: + if aud.lower() == target_audience.lower(): + return True + return False def get_connection_settings(self, aud: Any) -> JwtConnectionSettings | None: if not aud or not isinstance(aud, str): 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/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 1041547c6..f63e9e637 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 @@ -66,7 +66,11 @@ async def wrapper(request: Request, *args, **kwargs): auth_header = request.headers.get("Authorization") - res = await _authorize_request(auth_header, auth_config) + res = await _authorize_request( + auth_header, + auth_config, + cache_key=request.app, + ) if isinstance(res, HttpResponse): return JSONResponse(content=res.body, status_code=res.status_code) request.state.claims_identity = res 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..0c045c256 100644 --- a/test_samples/agentic-test/src/agent.py +++ b/test_samples/agentic-test/src/agent.py @@ -31,14 +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) 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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.activity("message", auth_handlers=["AGENTIC"]) 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..5d3ce9a03 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, @@ -9,9 +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"] @@ -24,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/app_style/authorization_agent.py b/test_samples/app_style/authorization_agent.py index 00747608a..0957f96b4 100644 --- a/test_samples/app_style/authorization_agent.py +++ b/test_samples/app_style/authorization_agent.py @@ -26,13 +26,11 @@ 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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.message(re.compile(r"^(status|auth status|check status)", re.IGNORECASE)) @@ -300,5 +298,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..60e9f65ea 100644 --- a/test_samples/app_style/empty_agent.py +++ b/test_samples/app_style/empty_agent.py @@ -28,15 +28,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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) async def _help(context: TurnContext, _state: TurnState): @@ -61,7 +59,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..4a89f1151 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,56 +38,38 @@ # 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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.conversation_update("membersAdded") @@ -181,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("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..e08c69b7a 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, @@ -9,9 +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"] @@ -25,11 +23,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..f67862816 100644 --- a/test_samples/app_style/streaming_agent.py +++ b/test_samples/app_style/streaming_agent.py @@ -31,13 +31,11 @@ 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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.activity(ActivityTypes.invoke) @@ -112,5 +110,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..b2079ab5f 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) @@ -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 a84226c72..5ca183975 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) @@ -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 b054d7550..b2333d0bf 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) @@ -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 4a19fe461..314c02a28 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 @@ -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 2c476d92f..2c5a072b0 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 @@ -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/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..8a501e1da 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,29 @@ 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/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 27d47f486..bcd70e0b3 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,21 +42,19 @@ # 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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) async def _help(context: TurnContext, _state: TurnState): @@ -110,16 +107,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..43cd2a686 100644 --- a/test_samples/extensions/extension-starter/src/sample/app.py +++ b/test_samples/extensions/extension-starter/src/sample/app.py @@ -23,12 +23,10 @@ # 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) -APP = AgentApplication[TurnState]( - storage=STORAGE, authorization=AUTHORIZATION, **agents_sdk_config -) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, config=agents_sdk_config) +APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @APP.activity("message", rank=RouteRank.LAST) 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..09a4237a0 100644 --- a/test_samples/fastapi/authorization_agent.py +++ b/test_samples/fastapi/authorization_agent.py @@ -40,13 +40,11 @@ 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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.message(re.compile(r"^/(status|auth status|check status)", re.IGNORECASE)) @@ -138,9 +136,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 +161,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..09f62ede5 100644 --- a/test_samples/fastapi/empty_agent.py +++ b/test_samples/fastapi/empty_agent.py @@ -29,19 +29,15 @@ 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 -) +AGENT_APP = AgentApplication[TurnState](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 +80,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..b78fd9d7f 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, ) @@ -195,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 af6fb7115..80c64490c 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,12 @@ 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 +26,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..edb4c4297 100644 --- a/test_samples/hosting_msteams/task-modules/src/agent.py +++ b/test_samples/hosting_msteams/task-modules/src/agent.py @@ -41,18 +41,19 @@ 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..53a0eeb3a 100644 --- a/test_samples/otel/quickstart/src/agent.py +++ b/test_samples/otel/quickstart/src/agent.py @@ -26,14 +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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.conversation_update("membersAdded") 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..fd04f84fd 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, @@ -13,9 +13,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: @@ -32,8 +30,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..7b67c948a 100644 --- a/test_samples/otel/zero-code/src/agent.py +++ b/test_samples/otel/zero-code/src/agent.py @@ -27,13 +27,11 @@ 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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.conversation_update("membersAdded") 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..c6e835893 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,15 +6,14 @@ 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__) 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: logger.info("Request received at /api/messages endpoint.") @@ -27,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["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..8d2de5103 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, @@ -9,9 +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"] @@ -21,12 +19,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..556d09476 100644 --- a/test_samples/teams_extension/task_modules_agent.py +++ b/test_samples/teams_extension/task_modules_agent.py @@ -34,17 +34,18 @@ 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 +277,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..da066e0e4 100644 --- a/test_samples/tethered-agent/src/agent.py +++ b/test_samples/tethered-agent/src/agent.py @@ -23,14 +23,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 -) +AGENT_APP = AgentApplication[TurnState](storage=STORAGE, authorization=AUTHORIZATION) @AGENT_APP.conversation_update("membersAdded") 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..5d3ce9a03 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, @@ -9,9 +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"] @@ -24,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/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 98708c9e3..516de3736 100644 --- a/tests/authentication_entra_auth_sidecar/test_settings.py +++ b/tests/authentication_entra_auth_sidecar/test_settings.py @@ -2,9 +2,10 @@ # 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 ( +from microsoft_agents.authentication.entra_auth_sidecar import ( + SidecarAuth, SidecarConnectionSettings, ) from microsoft_agents.authentication.entra_auth_sidecar.errors import ( @@ -14,32 +15,56 @@ 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" + + 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"] -class TestSettingsFromConfiguration: def test_reads_sidecar_settings_from_upper_case_kwargs(self): config = AgentAuthConfiguration( auth_type="EntraAuthSideCar", @@ -62,11 +87,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( @@ -152,52 +177,78 @@ 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("config_type", [AgentAuthConfiguration, Configuration]) + def test_unrecognized_bypass_value_from_configuration_is_rejected( + 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, + ) + 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_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..a74cb1d9b --- /dev/null +++ b/tests/authentication_msal/test_connection_settings.py @@ -0,0 +1,212 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import pytest + +from microsoft_agents.activity.config import Configuration, load_configuration_from_env +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 + 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]) +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_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/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/copilotstudio_client/test_console_sample.py b/tests/copilotstudio_client/test_console_sample.py index 5107f2450..f4cb5d127 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 @@ -81,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 + ) 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 new file mode 100644 index 000000000..0b909fa4d --- /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(ValueError, 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/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 f676a09c1..269bc7ed8 100644 --- a/tests/hosting_core/test_connection_manager.py +++ b/tests/hosting_core/test_connection_manager.py @@ -1,16 +1,21 @@ # 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 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, + JwtTokenValidator, ) from microsoft_agents.hosting.core.authorization._helpers import ( _CallableTokenCredential, @@ -39,6 +44,188 @@ 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": {"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.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()) @@ -87,7 +274,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, @@ -250,11 +439,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 +452,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_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 669945c92..e0109f4de 100644 --- a/tests/hosting_core/test_log_config.py +++ b/tests/hosting_core/test_log_config.py @@ -3,7 +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, @@ -73,16 +74,38 @@ 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( { - "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, ) } ) @@ -106,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) @@ -130,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_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) 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():