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
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

## Developer Experience
- Building packages with `py.typed` files for improved typing support
- Simplified the anonymous identity scenarios when using `ClaimsIdentity`. Added a new property `ClaimsIdentity.allow_anonymous` and deprecated the `ClaimsIdentity.is_authenticated` flag.
- In the construction of the inbound `Activity` instance, raw `Entity` JSON is automatically deserialized into known derived `Entity` classes such as `ProductInfo`, `Mention`, `AIEntity`, and more.

# Microsoft 365 Agents SDK for Python - Release Notes v1.3.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def test_cloud_adapter_configures_user_token_client_endpoint(
"token_service_endpoint": token_service_endpoint
},
)
identity = ClaimsIdentity({"aud": "test-app-id"}, True)
identity = ClaimsIdentity({"aud": "test-app-id"})
context = TurnContext(
adapter,
Activity(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def test_cloud_adapter_configures_user_token_client_endpoint(
"token_service_endpoint": token_service_endpoint
},
)
identity = ClaimsIdentity({"aud": "test-app-id"}, True)
identity = ClaimsIdentity({"aud": "test-app-id"})
context = TurnContext(
adapter,
Activity(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async def _claims_handler(request):
identity = request["claims_identity"]
return web.json_response(
{
"authenticated": identity.is_authenticated,
"allow_anonymous": identity.allow_anonymous,
"authentication_type": identity.authentication_type,
}
)
Expand Down Expand Up @@ -73,8 +73,8 @@ async def test_aiohttp_global_middleware_allows_anonymous_request_from_env_confi

assert response.status == 200
assert await response.json() == {
"authenticated": False,
"authentication_type": "Anonymous",
"allow_anonymous": True,
"authentication_type": None,
}


Expand Down Expand Up @@ -105,7 +105,7 @@ async def test_aiohttp_global_middleware_accepts_real_service_connection_token(
response = await client.get("/", headers={"Authorization": f"Bearer {token}"})

assert response.status == 200
assert (await response.json())["authenticated"] is True
assert (await response.json())["allow_anonymous"] is False


@_requires_real_service_connection
Expand Down Expand Up @@ -138,8 +138,8 @@ async def test_aiohttp_decorator_allows_anonymous_request_from_env_config(

assert response.status == 200
assert await response.json() == {
"authenticated": False,
"authentication_type": "Anonymous",
"allow_anonymous": True,
"authentication_type": None,
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
def _claims_payload(request: Request):
identity = request.state.claims_identity
return {
"authenticated": identity.is_authenticated,
"allow_anonymous": identity.allow_anonymous,
"authentication_type": identity.authentication_type,
}

Expand Down Expand Up @@ -78,8 +78,8 @@ def test_fastapi_global_middleware_allows_anonymous_request_from_env_config():

assert response.status_code == 200
assert response.json() == {
"authenticated": False,
"authentication_type": "Anonymous",
"allow_anonymous": True,
"authentication_type": None,
}


Expand All @@ -105,7 +105,7 @@ async def test_fastapi_global_middleware_accepts_real_service_connection_token()
response = client.get("/", headers={"Authorization": f"Bearer {token}"})

assert response.status_code == 200
assert response.json()["authenticated"] is True
assert response.json()["allow_anonymous"] is False


@_requires_real_service_connection
Expand Down Expand Up @@ -134,8 +134,8 @@ def test_fastapi_decorator_allows_anonymous_request_from_env_config():

assert response.status_code == 200
assert response.json() == {
"authenticated": False,
"authentication_type": "Anonymous",
"allow_anonymous": True,
"authentication_type": None,
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,8 @@ async def process_request(
span.share(activity=activity)

# Get claims identity (default to anonymous if not set by middleware)
claims_identity: (
ClaimsIdentity
) = request.get_claims_identity() or ClaimsIdentity(
{}, False, authentication_type="Anonymous"
claims_identity: ClaimsIdentity = (
request.get_claims_identity() or ClaimsIdentity()
)

# Validate required activity fields
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ def identity_from_claims(claims: dict[str, str]) -> ClaimsIdentity:
:return: Reconstituted claims identity.
:rtype: :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`
"""
return ClaimsIdentity(claims=dict(claims), is_authenticated=True)
if not claims:
return ClaimsIdentity()
return ClaimsIdentity(claims=dict(claims))

# ------------------------------------------------------------------
# Validation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,87 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
from typing import Optional

import warnings

from typing import Any

from .authentication_constants import AuthenticationConstants


class ClaimsIdentity:
"""Represents an identity with associated claims and authentication information.

For context, this class merges the functionality of ClaimsIdentity and AgentClaims from .NET
"""

claims: dict[str, Any]
authentication_type: str | None
security_token: str | None # deprecated, will be removed in future versions

def __init__(
self,
claims: dict[str, str],
is_authenticated: bool,
authentication_type: Optional[str] = None,
security_token: Optional[str] = None,
claims: dict[str, Any] | None = None,
is_authenticated: bool | None = None,
authentication_type: str | None = None,
security_token: str | None = None,
):
"""Creates a new instance of the ClaimsIdentity class.

:param claims: A dictionary of claims associated with the identity.
:param is_authenticated: A boolean indicating whether the identity is authenticated. (Deprecated)
:param authentication_type: A string representing the type of authentication used.
:param security_token: The security token associated with the identity.
"""
if claims is None:
claims = {}
self.claims = claims
self.is_authenticated = is_authenticated
if is_authenticated is not None:
warnings.warn(
"The 'is_authenticated' parameter is deprecated and will be removed in future versions.",
DeprecationWarning,
stacklevel=2,
)

self.authentication_type = authentication_type
self.security_token = security_token
self._is_authenticated = is_authenticated

def get_claim_value(self, claim_type: str) -> Any:
"""Gets the value of a specific claim type from the claims dictionary.

def get_claim_value(self, claim_type: str) -> Optional[str]:
:param claim_type: The type of claim to retrieve.
:return: The value of the claim if found, otherwise None.
"""
return self.claims.get(claim_type)

def get_app_id(self) -> Optional[str]:
@property
def allow_anonymous(self) -> bool:
"""Returns True if the identity allows anonymous access, otherwise False."""
return (
not self.authentication_type
or self.authentication_type.lower() == "anonymous"
) and not self.claims

@property
def is_authenticated(self) -> bool:
"""Returns True if the identity is authenticated, otherwise False."""
warnings.warn(
"The 'is_authenticated' property is deprecated and will be removed in future versions.",
DeprecationWarning,
stacklevel=2,
)
return bool(self.claims)

@is_authenticated.setter
def is_authenticated(self, value: bool) -> None:
"""(Deprecated). This is now a no-op."""
warnings.warn(
"The 'is_authenticated' property is deprecated and will be removed in future versions.",
DeprecationWarning,
stacklevel=2,
)

def get_app_id(self) -> str | None:
"""
Gets the AppId from the current ClaimsIdentity.

Expand All @@ -32,7 +92,7 @@ def get_app_id(self) -> Optional[str]:
AuthenticationConstants.AUDIENCE_CLAIM, None
) or self.claims.get(AuthenticationConstants.APP_ID_CLAIM, None)

def get_outgoing_app_id(self) -> Optional[str]:
def get_outgoing_app_id(self) -> str | None:
"""
Gets the outgoing AppId from current claims.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,12 +154,12 @@ async def validate_token(self, token: str) -> ClaimsIdentity:
)

logger.debug("JWT token validated successfully.")
return ClaimsIdentity(decoded_token, True, security_token=token)
return ClaimsIdentity(decoded_token, security_token=token)

def get_anonymous_claims(self) -> ClaimsIdentity:
"""Returns a ClaimsIdentity for an anonymous user."""
logger.debug("Returning anonymous claims identity.")
return ClaimsIdentity({}, False, authentication_type="Anonymous")
return ClaimsIdentity()


def _build_jwks_uri(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,10 +252,16 @@ async def create_conversation( # pylint: disable=arguments-differ
claims_identity = self.create_claims_identity(agent_app_id)
claims_identity.claims[AuthenticationConstants.SERVICE_URL_CLAIM] = service_url

use_anonymous_auth_callback = claims_identity.allow_anonymous

# Create the connector client to use for outbound requests.
connector_client = (
await self._channel_service_client_factory.create_connector_client(
None, claims_identity, service_url, audience
None,
claims_identity,
service_url,
audience,
use_anonymous=use_anonymous_auth_callback,
)
)

Expand Down Expand Up @@ -285,7 +291,7 @@ async def create_conversation( # pylint: disable=arguments-differ
# Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.)
user_token_client = (
await self._channel_service_client_factory.create_user_token_client(
context, claims_identity
context, claims_identity, use_anonymous_auth_callback
)
)
context.services.set(UserTokenClientBase, user_token_client)
Expand All @@ -307,6 +313,8 @@ async def process_proactive(
callback: Callable[[TurnContext], Awaitable],
):

use_anonymous_auth_callback = claims_identity.allow_anonymous

# Create a turn context and run the pipeline.
context = self._create_turn_context(
claims_identity,
Expand All @@ -316,7 +324,7 @@ async def process_proactive(

user_token_client = (
await self._channel_service_client_factory.create_user_token_client(
context, claims_identity
context, claims_identity, use_anonymous_auth_callback
)
)
context.services.set(UserTokenClientBase, user_token_client)
Expand All @@ -327,7 +335,11 @@ async def process_proactive(
# Create the connector client to use for outbound requests.
connector_client = (
await self._channel_service_client_factory.create_connector_client(
context, claims_identity, continuation_activity.service_url, audience
context,
claims_identity,
continuation_activity.service_url,
audience,
use_anonymous=use_anonymous_auth_callback,
)
)
context.services.set(ConnectorClientBase, connector_client)
Expand Down Expand Up @@ -390,12 +402,7 @@ async def process_activity(
else:
outgoing_audience = AuthenticationConstants.AGENTS_SDK_SCOPE

use_anonymous_auth_callback = False
if (
not claims_identity.is_authenticated
and claims_identity.authentication_type == "Anonymous"
):
use_anonymous_auth_callback = True
use_anonymous_auth_callback = claims_identity.allow_anonymous

# Create a turn context and run the pipeline.
context = self._create_turn_context(
Expand Down Expand Up @@ -456,7 +463,6 @@ def create_claims_identity(self, agent_app_id: str = "") -> ClaimsIdentity:
AuthenticationConstants.AUDIENCE_CLAIM: agent_app_id,
AuthenticationConstants.APP_ID_CLAIM: agent_app_id,
},
False,
)

@staticmethod
Expand Down
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ filterwarnings =
ignore::DeprecationWarning:setuptools.*
ignore::PendingDeprecationWarning
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
# pytest-asyncio warnings that are safe to ignore
ignore:.*deprecated.*asyncio.*:DeprecationWarning:pytest_asyncio.*

Expand Down
6 changes: 6 additions & 0 deletions tests/hosting_core/app/proactive/test_conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ def test_identity_from_claims_is_authenticated(self):
identity = Conversation.identity_from_claims(claims)
assert identity.is_authenticated is True

def test_identity_from_empty_claims_allows_anonymous(self):
Comment thread
rodrigobr-msft marked this conversation as resolved.
identity = Conversation.identity_from_claims({})

assert identity.claims == {}
assert identity.allow_anonymous is True

def test_identity_from_claims_preserves_values(self):
claims = {"aud": "app-id", "tid": "tenant", "ver": "2.0"}
identity = Conversation.identity_from_claims(claims)
Expand Down
Loading
Loading