Skip to content
Merged
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
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
rodrigobr-msft marked this conversation as resolved.
Resolved
from .errors import (
SidecarAuthError,
SidecarUnavailableError,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading