Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# Microsoft 365 Agents SDK for Python - Release Notes v1.10.0 (Unreleased)

**Release Date:** Unreleased
**Previous Version:** 1.9.0 (Released 2026-10-19)

## Major Features & Enhancements

- **Shared Configuration API**: Added a dictionary-backed `Configuration` API with nested path and alias lookup, typed getters, wildcard collection, configurable settings binding, and mapping/default resolution with indexed-list normalization. Retained the existing `load_configuration_from_env()` return contract (#636).
- **JWT Authorization Configuration**: Added `ConnectionSettingsBase`, `JwtConnectionSettings`, and `TokenValidationConfiguration` to bind inbound JWT policies from application configuration, including explicit audiences, tenant and anonymous-access overrides, and per-connection issuer settings. Retained legacy `AgentAuthConfiguration` support and added application-scoped validator caching without changing `Connections` or `ConnectionManager` (#638).

- **Explicit JWT Audience Lists**: Scalar and delimited audience strings now bind as explicit allow-lists instead of falling back to all configured connection client IDs. Missing or empty audiences continue to use connection defaults (#638).

---

# Microsoft 365 Agents SDK for Python - Release Notes v1.9.0

**Release Date:** 2026-10-19
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ def get_str_list(
name = self._resolve_path(_PATH_SEPARATOR.join(path) or keys[0])
before_split = _handle_str(val, None, name)
if before_split is None:
return default
return default if default is not None else []
if "," in before_split:
return [s.strip() for s in before_split.split(",")]
return [s.strip() for s in before_split.strip().split()]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,6 @@ async def _jwt_authorization_middleware(request: Request, handler):
"""
JWT Authorization Middleware for aiohttp endpoints.
"""
auth_config = cast(
AgentAuthConfiguration | None, request.app.get("agent_configuration", None)
)

auth_header = request.headers.get("Authorization")
if auth_header is not None:
# aiohttp-specific tolerance: trailing whitespace (spaces/tabs) after
Expand All @@ -52,7 +48,11 @@ async def _jwt_authorization_middleware(request: Request, handler):
return json_response(_GENERIC_AUTH_ERROR, status=401)
auth_header = f"Bearer {token}"

res = await _authorize_request(auth_header, auth_config)
res = await _authorize_request(
auth_header,
request.app.get("agent_configuration"),
cache_key=request.app,
)

if isinstance(res, HttpResponse):
return json_response(res.body, status=res.status_code)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@
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
Expand Down Expand Up @@ -109,3 +118,87 @@ def from_configuration(
) -> "_ConnectionSettingsBase":
"""Build base settings from an :class:`AgentAuthConfiguration`."""
return cls(**cls.base_kwargs_from_configuration(configuration))


_KNOWN_KWARGS_CACHE: dict[type[ConnectionSettingsBase], list[str]] = {}


@dataclass(frozen=True)
class ConnectionSettingsBase(_Configurable):
"""Provider-independent settings bound from Configuration.

Frozen fields cannot be reassigned, but scopes and extras remain mutable.
Treat those containers as read-only after handing settings to a provider.
"""

client_id: str | None = None
authority: str | None = None
tenant_id: str | None = None
alternate_blueprint_connection_name: str | None = None
scopes: list[str] = field(default_factory=list)
_extras: Configuration = field(default_factory=Configuration)

@property
def extras(self) -> Configuration:
"""Return unrecognized provider settings, not an immutable copy."""
return self._extras

@classmethod
def _from_agent_auth_config(cls, agent_auth_config: AgentAuthConfiguration) -> Self:
attrs = dict(vars(agent_auth_config))
attrs.update(attrs.pop("provider_settings", {}))
attrs["ISSUERS"] = agent_auth_config.ISSUERS
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)
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@

from ._authorize_request import _authorize_request
from .jwt_token_validator import JwtTokenValidator
from .jwt_connection_settings import JwtConnectionSettings
from .token_validation_configuration import TokenValidationConfiguration

__all__ = [
"JwtTokenValidator",
"_authorize_request",
"JwtConnectionSettings",
"TokenValidationConfiguration",
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import logging

from jwt import PyJWTError
from typing import Any, Mapping
from weakref import WeakKeyDictionary

from microsoft_agents.activity.config import Configuration
from microsoft_agents.hosting.core.http import HttpResponse

from ..agent_auth_configuration import AgentAuthConfiguration
Expand All @@ -13,9 +16,33 @@

logger = logging.getLogger(__name__)

_TOKEN_VALIDATION_CACHE: WeakKeyDictionary[Any, JwtTokenValidator] = WeakKeyDictionary()


def _resolve_token_validator(
auth_config: Configuration | AgentAuthConfiguration | None,
cache_key: Any = None,
) -> JwtTokenValidator | None:

if cache_key is not None and cache_key in _TOKEN_VALIDATION_CACHE:
return _TOKEN_VALIDATION_CACHE[cache_key]
Comment thread
rodrigobr-msft marked this conversation as resolved.
Resolved

if auth_config is not None:
config: AgentAuthConfiguration | Configuration = auth_config
if not isinstance(auth_config, AgentAuthConfiguration):
config = Configuration(auth_config)
validator = JwtTokenValidator(config)
if cache_key is not None:
_TOKEN_VALIDATION_CACHE[cache_key] = validator
return validator
return None


async def _authorize_request(
authorization_header: str | None, auth_config: AgentAuthConfiguration | None
authorization_header: str | None,
auth_config: Configuration | AgentAuthConfiguration | None,
*,
cache_key: Any = None
) -> ClaimsIdentity | HttpResponse:
"""
Authorizes a request based on the provided JWT token in the Authorization header.
Expand All @@ -25,16 +52,16 @@ async def _authorize_request(
:return: A ClaimsIdentity object if the token is valid, or an HttpResponse with an
error message and status code if the token is invalid or missing.
"""
validator = _resolve_token_validator(auth_config, cache_key)

if auth_config is None:
if not validator:
return HttpResponse(
body={"error": "Agent Authentication configuration not found"},
status_code=500,
)
validator = JwtTokenValidator(auth_config)

if not authorization_header:
if auth_config.ANONYMOUS_ALLOWED:
if validator.config.anonymous_allowed:
claims_identity = validator.get_anonymous_claims()
return claims_identity
return HttpResponse(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from __future__ import annotations

from dataclasses import dataclass, field

from microsoft_agents.activity.config import Configuration, xpath

from .._entra_issuers import default_connection_issuers
from ..connection_settings_base import ConnectionSettingsBase


@dataclass(frozen=True)
class JwtConnectionSettings(ConnectionSettingsBase):
"""Provider-independent connection settings for inbound JWT validation."""

validate_issuer: bool = False
issuers: list[str] = field(default_factory=list)
anonymous_allowed: bool = False

@classmethod
def _get_init_kwargs(cls, config: Configuration) -> dict:
base = super()._get_init_kwargs(config)
issuers = config.get_str_list(xpath("issuers"))
issuers = issuers or default_connection_issuers(
base.get("tenant_id"), base.get("authority")
)
base.update(
{
"validate_issuer": config.get_bool(
xpath("validate_issuer"), default=False
),
"issuers": issuers,
"anonymous_allowed": config.get_bool(
xpath("anonymous_allowed"), default=False
),
}
)
return base
Loading
Loading