Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ class Channels(str, Enum):
webchat = "webchat"
"""WebChat channel."""

copilot_studio = "pva-studio"
"""Microsoft Copilot Studio channel."""

# TODO: validate the need of Self annotations in the following methods
@staticmethod
def supports_suggested_actions(channel_id: Self, button_cnt: int = 100) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ class RoleTypes(str, Enum):
user = "user"
agent = "bot"
skill = "skill"
connector_user = "connectoruser"
agentic_identity = "agenticAppInstance"
agentic_user = "agenticUser"
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from ._handlers import (
_UserAuthorization,
AgenticUserAuthorization,
ConnectorUserAuthorization,
_AuthorizationHandler,
)

Expand All @@ -20,4 +21,5 @@
"_SignInResponse",
"_UserAuthorization",
"AgenticUserAuthorization",
"ConnectorUserAuthorization",
]
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
"""

from .agentic_user_authorization import AgenticUserAuthorization
from .connector_user_authorization import ConnectorUserAuthorization
from ._user_authorization import _UserAuthorization
from ._authorization_handler import _AuthorizationHandler

__all__ = [
"AgenticUserAuthorization",
"ConnectorUserAuthorization",
"_UserAuthorization",
"_AuthorizationHandler",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

import logging
import jwt
from datetime import datetime, timezone, timedelta
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'timedelta' is not used.

Suggested change
from datetime import datetime, timezone, timedelta
from datetime import datetime, timezone

Copilot uses AI. Check for mistakes.
from typing import Optional

from microsoft_agents.activity import TokenResponse

from ....turn_context import TurnContext
from ....storage import Storage
from ....authorization import Connections
from ..auth_handler import AuthHandler
from ._authorization_handler import _AuthorizationHandler
from .._sign_in_response import _SignInResponse

logger = logging.getLogger(__name__)


class ConnectorUserAuthorization(_AuthorizationHandler):
"""
User Authorization handling for Copilot Studio Connector requests.
Extracts token from the identity and performs OBO token exchange.
"""

def __init__(
self,
storage: Storage,
connection_manager: Connections,
auth_handler: Optional[AuthHandler] = None,
*,
auth_handler_id: Optional[str] = None,
auth_handler_settings: Optional[dict] = None,
**kwargs,
) -> None:
"""
Creates a new instance of ConnectorUserAuthorization.

:param storage: The storage system to use for state management.
:type storage: Storage
:param connection_manager: The connection manager for OAuth providers.
:type connection_manager: Connections
:param auth_handler: Configuration for OAuth provider.
:type auth_handler: AuthHandler, Optional
:param auth_handler_id: Optional ID of the auth handler.
:type auth_handler_id: str, Optional
:param auth_handler_settings: Optional settings dict for the auth handler.
:type auth_handler_settings: dict, Optional
"""
super().__init__(
storage,
connection_manager,
auth_handler,
auth_handler_id=auth_handler_id,
auth_handler_settings=auth_handler_settings,
**kwargs,
)

async def _sign_in(
self, context: TurnContext, scopes: Optional[list[str]] = None
) -> _SignInResponse:
"""
For connector requests, there is no separate sign-in flow.
The token is extracted from the identity.

:param context: The turn context for the current turn of conversation.
:type context: TurnContext
:param scopes: Optional list of scopes (unused for connector auth).
:type scopes: Optional[list[str]], Optional
:return: A SignInResponse with the extracted token.
:rtype: _SignInResponse
"""
# Connector auth uses the token from the request, not a separate sign-in flow
token_response = await self.get_refreshed_token(context)
return _SignInResponse(
token_response=token_response, success=bool(token_response)
)

async def get_refreshed_token(
self,
context: TurnContext,
exchange_connection: Optional[str] = None,
exchange_scopes: Optional[list[str]] = None,
) -> TokenResponse:
"""
Gets the connector user token and optionally exchanges it via OBO.

:param context: The turn context for the current turn of conversation.
:type context: TurnContext
:param exchange_connection: Optional name of the connection to use for token exchange.
:type exchange_connection: Optional[str], Optional
:param exchange_scopes: Optional list of scopes to request during token exchange.
:type exchange_scopes: Optional[list[str]], Optional
:return: The token response, potentially after OBO exchange.
:rtype: TokenResponse
"""
token_response = self._create_token_response(context)

# Check if token is expired
if token_response.expiration:
try:
# Parse ISO 8601 format
expiration = datetime.fromisoformat(
token_response.expiration.replace("Z", "+00:00")
)
if expiration <= datetime.now(timezone.utc):
raise ValueError(
f"Unexpected connector token expiration for handler: {self._id}"
)
except (ValueError, AttributeError) as ex:
logger.error(
f"Error checking token expiration for handler {self._id}: {ex}"
)
raise
Comment on lines +109 to +117
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent error handling: when the token is expired (line 110), a ValueError is raised with a message about expiration, but when ValueError is caught on line 113, it could be from either the expiration check or from parsing the date. The error message and handling don't distinguish between these cases. Consider using a more specific exception type for the expiration case.

Copilot uses AI. Check for mistakes.

# Perform OBO exchange if configured
try:
return await self._handle_obo(
context, token_response, exchange_connection, exchange_scopes
)
except Exception:
await self._sign_out(context)
raise

async def _sign_out(self, context: TurnContext) -> None:
"""
Sign-out is a no-op for connector authorization.

:param context: The turn context for the current turn of conversation.
:type context: TurnContext
"""
# No concept of sign-out with ConnectorAuth
logger.debug("Sign-out called for ConnectorUserAuthorization (no-op)")
pass
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary 'pass' statement.

Suggested change
pass

Copilot uses AI. Check for mistakes.

async def _handle_obo(
self,
context: TurnContext,
input_token_response: TokenResponse,
exchange_connection: Optional[str] = None,
exchange_scopes: Optional[list[str]] = None,
) -> TokenResponse:
"""
Exchanges a token for another token with different scopes via OBO flow.

:param context: The context object for the current turn.
:type context: TurnContext
:param input_token_response: The input token to exchange.
:type input_token_response: TokenResponse
:param exchange_connection: Optional connection name for exchange.
:type exchange_connection: Optional[str]
:param exchange_scopes: Optional scopes for the exchanged token.
:type exchange_scopes: Optional[list[str]]
:return: The token response after exchange, or the original if exchange not configured.
:rtype: TokenResponse
"""
if not input_token_response:
return input_token_response

connection_name = exchange_connection or self._handler.obo_connection_name
scopes = exchange_scopes or self._handler.scopes

# If OBO is not configured, return token as-is
if not connection_name or not scopes:
return input_token_response

# Check if token is exchangeable
if not input_token_response.is_exchangeable():
# TODO: (connector) Should raise an error instead of just returning
return input_token_response
Comment on lines +172 to +173
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] TODO comment suggests that non-exchangeable tokens should raise an error, but the current implementation silently returns the original token. This could lead to confusion when the OBO exchange is expected to happen but doesn't. Consider raising an appropriate error or logging a warning at minimum.

Suggested change
# TODO: (connector) Should raise an error instead of just returning
return input_token_response
logger.warning(f"Token provided to OBO exchange is not exchangeable for handler: {self._id}")
raise ValueError("Token is not exchangeable and OBO exchange was requested.")

Copilot uses AI. Check for mistakes.
Comment on lines +172 to +173
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO comment indicates this silent failure path should raise an error instead. This should be addressed before merging, as it affects token exchange behavior.

Suggested change
# TODO: (connector) Should raise an error instead of just returning
return input_token_response
raise ValueError("Input token is not exchangeable for OBO flow")

Copilot uses AI. Check for mistakes.

# Get the connection that supports OBO
token_provider = self._connection_manager.get_connection(connection_name)
if not token_provider:
# TODO: (connector) use resource errors
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message should use the ErrorResources pattern for consistency with the rest of the codebase, as indicated by this TODO comment.

Copilot uses AI. Check for mistakes.
raise ValueError(f"Connection '{connection_name}' not found")

# Perform the OBO exchange
# Note: In Python, the acquire_token_on_behalf_of method is on the AccessTokenProviderBase
token = await token_provider.acquire_token_on_behalf_of(
scopes=scopes,
user_assertion=input_token_response.token,
)
return TokenResponse(token=token) if token else None

def _create_token_response(self, context: TurnContext) -> TokenResponse:
"""
Creates a TokenResponse from the security token in the turn context identity.

:param context: The turn context for the current turn of conversation.
:type context: TurnContext
:return: A TokenResponse containing the extracted token.
:rtype: TokenResponse
:raises ValueError: If the identity doesn't have a security token.
"""
if not context.identity or not hasattr(context.identity, "security_token"):
raise ValueError(
f"Unexpected connector request - no security token found for handler: {self._id}"
)

security_token = context.identity.security_token
if not security_token:
raise ValueError(
f"Unexpected connector request - security token is None for handler: {self._id}"
)

token_response = TokenResponse(token=security_token)

# Try to extract expiration and check if exchangeable
try:
# TODO: (connector) validate this decoding
jwt_token = jwt.decode(security_token, options={"verify_signature": False})
Comment on lines +214 to +215
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT token decoding is performed without signature validation (verify_signature: False). While this may be intentional for extracting metadata, the TODO suggests this needs validation to ensure security requirements are met.

Suggested change
# TODO: (connector) validate this decoding
jwt_token = jwt.decode(security_token, options={"verify_signature": False})
# Validate JWT decoding with signature verification
# TODO: Replace 'YOUR_PUBLIC_KEY' and ['RS256'] with actual key and algorithms as appropriate
jwt_token = jwt.decode(
security_token,
"YOUR_PUBLIC_KEY", # Replace with actual public key or secret
algorithms=["RS256"], # Replace with actual algorithm(s)
)

Copilot uses AI. Check for mistakes.

# Set expiration if present
if "exp" in jwt_token:
# JWT exp is in Unix timestamp (seconds since epoch)
expiration = datetime.fromtimestamp(jwt_token["exp"], tz=timezone.utc)
# Convert to ISO 8601 format
token_response.expiration = expiration.isoformat()

except Exception as ex:
logger.warning(f"Failed to parse JWT token for handler {self._id}: {ex}")
raise ex
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreachable code: the raise ex statement on line 226 will never be executed because if an exception occurs, it will be caught and re-raised on line 227. Additionally, the comment on line 227 is unreachable and misleading since exceptions are being re-raised rather than allowed to continue.

Suggested change
raise ex

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unreachable comment after raise ex on line 226. Either remove the comment or remove the raise statement if the intention is to continue without expiration info.

Suggested change
raise ex

Copilot uses AI. Check for mistakes.
# If we can't parse the token, we'll still return it without expiration info

return token_response
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from ._sign_in_response import _SignInResponse
from ._handlers import (
AgenticUserAuthorization,
ConnectorUserAuthorization,
_UserAuthorization,
_AuthorizationHandler,
)
Expand All @@ -29,6 +30,7 @@
AUTHORIZATION_TYPE_MAP = {
"userauthorization": _UserAuthorization,
"agenticuserauthorization": AgenticUserAuthorization,
"connectoruserauthorization": ConnectorUserAuthorization,
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ def __init__(
claims: dict[str, str],
is_authenticated: bool,
authentication_type: Optional[str] = None,
security_token: Optional[str] = None,
):
self.claims = claims
self.is_authenticated = is_authenticated
self.authentication_type = authentication_type
self.security_token = security_token

def get_claim_value(self, claim_type: str) -> Optional[str]:
return self.claims.get(claim_type)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async def validate_token(self, token: str) -> ClaimsIdentity:

# This probably should return a ClaimsIdentity
logger.debug("JWT token validated successfully.")
return ClaimsIdentity(decoded_token, True)
return ClaimsIdentity(decoded_token, True, security_token=token)

def get_anonymous_claims(self) -> ClaimsIdentity:
logger.debug("Returning anonymous claims identity.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
# Teams API
from .teams.teams_connector_client import TeamsConnectorClient

# MCS API
from .mcs.mcs_connector_client import MCSConnectorClient

__all__ = [
"ConnectorClient",
"UserTokenClient",
"UserTokenClientBase",
"TeamsConnectorClient",
"MCSConnectorClient",
"ConnectorClientBase",
"get_product_info",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .mcs_connector_client import MCSConnectorClient

__all__ = ["MCSConnectorClient"]
Loading