Skip to content

Commit a54797c

Browse files
authored
MCS Connector (Updated) (#295)
* MCS Connector mostly code complete, sample WIP, e2e testing WIP * Moving connector to correct path * Moving imports * Fix: minor bugs * WIP * Fix for MCSConnetor missing impl and other minor fixes, problems with serviceurl * Sample fixes, still more fixes needed for sample * Fixes for auth path * Some auth fixes * Exchange logic updated, E2E working as expected * fixing tests" * adding basic tests * Addressing pr comments * Fixing unit test regression * Fixing more PR comments * Fixing for auth tests * Updating auth tests * Updating auth tests pt 2 * Updating auth tests * More comment fixes
1 parent 6019cac commit a54797c

28 files changed

Lines changed: 1410 additions & 36 deletions

File tree

libraries/microsoft-agents-activity/microsoft_agents/activity/channels.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ class Channels(str, Enum):
6767
webchat = "webchat"
6868
"""WebChat channel."""
6969

70+
copilot_studio = "pva-studio"
71+
"""Microsoft Copilot Studio channel."""
72+
7073
# TODO: validate the need of Self annotations in the following methods
7174
@staticmethod
7275
def supports_suggested_actions(channel_id: Self, button_cnt: int = 100) -> bool:

libraries/microsoft-agents-activity/microsoft_agents/activity/role_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ class RoleTypes(str, Enum):
88
user = "user"
99
agent = "bot"
1010
skill = "skill"
11+
connector_user = "connectoruser"
1112
agentic_identity = "agenticAppInstance"
1213
agentic_user = "agenticUser"

libraries/microsoft-agents-activity/microsoft_agents/activity/token_response.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright (c) Microsoft Corporation. All rights reserved.
22
# Licensed under the MIT License.
33

4+
from typing import Optional
45
import jwt
56

67
from .agents_model import AgentsModel
@@ -31,7 +32,7 @@ def __bool__(self):
3132

3233
def is_exchangeable(self) -> bool:
3334
"""
34-
Checks if a token is exchangeable (has api:// audience).
35+
Checks if a token is exchangeable.
3536
3637
:param token: The token to check.
3738
:type token: str
@@ -40,7 +41,33 @@ def is_exchangeable(self) -> bool:
4041
try:
4142
# Decode without verification to check the audience
4243
payload = jwt.decode(self.token, options={"verify_signature": False})
44+
45+
idtyp = payload.get("idtyp")
46+
if idtyp == "user":
47+
return False
48+
4349
aud = payload.get("aud")
44-
return isinstance(aud, str) and aud.startswith("api://")
50+
app_id = self._get_app_id_from_token_payload(payload)
51+
return isinstance(aud, str) and app_id in aud
4552
except Exception:
4653
return False
54+
55+
@staticmethod
56+
def _get_app_id_from_token_payload(token_payload: dict) -> Optional[str]:
57+
"""
58+
Extracts the appId from the token's claims.
59+
60+
:return: The appId if found, otherwise None.
61+
"""
62+
try:
63+
token_version = token_payload.get("ver", None)
64+
app_id = None
65+
66+
if not token_version or token_version == "1.0":
67+
app_id = token_payload.get("appid", None)
68+
elif token_version == "2.0":
69+
app_id = token_payload.get("azp", None)
70+
71+
return app_id
72+
except Exception:
73+
return None

libraries/microsoft-agents-hosting-aiohttp/microsoft_agents/hosting/aiohttp/jwt_authorization_middleware.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ async def jwt_authorization_middleware(request: Request, handler):
2424
print(f"JWT validation error: {e}")
2525
return json_response({"error": str(e)}, status=401)
2626
else:
27-
if not auth_config or not auth_config.CLIENT_ID:
28-
# TODO: Refine anonymous strategy
27+
if auth_config.ANONYMOUS_ALLOWED:
2928
request["claims_identity"] = token_validator.get_anonymous_claims()
3029
else:
3130
return json_response(

libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ._handlers import (
1010
_UserAuthorization,
1111
AgenticUserAuthorization,
12+
ConnectorUserAuthorization,
1213
_AuthorizationHandler,
1314
)
1415

@@ -20,4 +21,5 @@
2021
"_SignInResponse",
2122
"_UserAuthorization",
2223
"AgenticUserAuthorization",
24+
"ConnectorUserAuthorization",
2325
]

libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/oauth/_handlers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
"""
55

66
from .agentic_user_authorization import AgenticUserAuthorization
7+
from .connector_user_authorization import ConnectorUserAuthorization
78
from ._user_authorization import _UserAuthorization
89
from ._authorization_handler import _AuthorizationHandler
910

1011
__all__ = [
1112
"AgenticUserAuthorization",
13+
"ConnectorUserAuthorization",
1214
"_UserAuthorization",
1315
"_AuthorizationHandler",
1416
]
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""
2+
Copyright (c) Microsoft Corporation. All rights reserved.
3+
Licensed under the MIT License.
4+
"""
5+
6+
import logging
7+
import jwt
8+
from datetime import datetime, timezone
9+
from typing import Optional
10+
11+
from microsoft_agents.activity import TokenResponse
12+
from microsoft_agents.hosting.core.errors import ErrorResources
13+
14+
from ...._oauth._flow_state import _FlowStateTag
15+
from ....turn_context import TurnContext
16+
from ....storage import Storage
17+
from ....authorization import Connections
18+
from ..auth_handler import AuthHandler
19+
from ._authorization_handler import _AuthorizationHandler
20+
from .._sign_in_response import _SignInResponse
21+
22+
logger = logging.getLogger(__name__)
23+
24+
25+
class ConnectorUserAuthorization(_AuthorizationHandler):
26+
"""
27+
User Authorization handling for Copilot Studio Connector requests.
28+
Extracts token from the identity and performs OBO token exchange.
29+
"""
30+
31+
def __init__(
32+
self,
33+
storage: Storage,
34+
connection_manager: Connections,
35+
auth_handler: Optional[AuthHandler] = None,
36+
*,
37+
auth_handler_id: Optional[str] = None,
38+
auth_handler_settings: Optional[dict] = None,
39+
**kwargs,
40+
) -> None:
41+
"""
42+
Creates a new instance of ConnectorUserAuthorization.
43+
44+
:param storage: The storage system to use for state management.
45+
:type storage: Storage
46+
:param connection_manager: The connection manager for OAuth providers.
47+
:type connection_manager: Connections
48+
:param auth_handler: Configuration for OAuth provider.
49+
:type auth_handler: AuthHandler, Optional
50+
:param auth_handler_id: Optional ID of the auth handler.
51+
:type auth_handler_id: str, Optional
52+
:param auth_handler_settings: Optional settings dict for the auth handler.
53+
:type auth_handler_settings: dict, Optional
54+
"""
55+
super().__init__(
56+
storage,
57+
connection_manager,
58+
auth_handler,
59+
auth_handler_id=auth_handler_id,
60+
auth_handler_settings=auth_handler_settings,
61+
**kwargs,
62+
)
63+
64+
async def _sign_in(
65+
self,
66+
context: TurnContext,
67+
exchange_connection: Optional[str] = None,
68+
exchange_scopes: Optional[list[str]] = None,
69+
) -> _SignInResponse:
70+
"""
71+
For connector requests, there is no separate sign-in flow.
72+
The token is extracted from the identity.
73+
74+
:param context: The turn context for the current turn of conversation.
75+
:type context: TurnContext
76+
:param exchange_connection: Optional connection name for token exchange.
77+
:type exchange_connection: Optional[str]
78+
:param exchange_scopes: Optional list of scopes (unused for connector auth).
79+
:type exchange_scopes: Optional[list[str]]
80+
:return: A SignInResponse with the extracted token.
81+
:rtype: _SignInResponse
82+
"""
83+
# Connector auth uses the token from the request, not a separate sign-in flow
84+
token_response = await self.get_refreshed_token(context)
85+
return _SignInResponse(
86+
token_response=token_response, tag=_FlowStateTag.COMPLETE
87+
)
88+
89+
async def get_refreshed_token(
90+
self,
91+
context: TurnContext,
92+
exchange_connection: Optional[str] = None,
93+
exchange_scopes: Optional[list[str]] = None,
94+
) -> TokenResponse:
95+
"""
96+
Gets the connector user token and optionally exchanges it via OBO.
97+
98+
:param context: The turn context for the current turn of conversation.
99+
:type context: TurnContext
100+
:param exchange_connection: Optional name of the connection to use for token exchange.
101+
:type exchange_connection: Optional[str], Optional
102+
:param exchange_scopes: Optional list of scopes to request during token exchange.
103+
:type exchange_scopes: Optional[list[str]], Optional
104+
:return: The token response, potentially after OBO exchange.
105+
:rtype: TokenResponse
106+
"""
107+
token_response = self._create_token_response(context)
108+
109+
# Check if token is expired
110+
if token_response.expiration:
111+
try:
112+
# Parse ISO 8601 format
113+
expiration = datetime.fromisoformat(
114+
token_response.expiration.replace("Z", "+00:00")
115+
)
116+
if expiration <= datetime.now(timezone.utc):
117+
raise ValueError(
118+
f"Unexpected connector token expiration for handler: {self._id}"
119+
)
120+
except (ValueError, AttributeError) as ex:
121+
logger.error(
122+
f"Error checking token expiration for handler {self._id}: {ex}"
123+
)
124+
raise
125+
126+
# Perform OBO exchange if configured
127+
try:
128+
return await self._handle_obo(
129+
context, token_response, exchange_connection, exchange_scopes
130+
)
131+
except Exception:
132+
await self._sign_out(context)
133+
raise
134+
135+
async def _sign_out(self, context: TurnContext) -> None:
136+
"""
137+
Sign-out is a no-op for connector authorization.
138+
139+
:param context: The turn context for the current turn of conversation.
140+
:type context: TurnContext
141+
"""
142+
# No concept of sign-out with ConnectorAuth
143+
logger.debug("Sign-out called for ConnectorUserAuthorization (no-op)")
144+
145+
async def _handle_obo(
146+
self,
147+
context: TurnContext,
148+
input_token_response: TokenResponse,
149+
exchange_connection: Optional[str] = None,
150+
exchange_scopes: Optional[list[str]] = None,
151+
) -> TokenResponse:
152+
"""
153+
Exchanges a token for another token with different scopes via OBO flow.
154+
155+
:param context: The context object for the current turn.
156+
:type context: TurnContext
157+
:param input_token_response: The input token to exchange.
158+
:type input_token_response: TokenResponse
159+
:param exchange_connection: Optional connection name for exchange.
160+
:type exchange_connection: Optional[str]
161+
:param exchange_scopes: Optional scopes for the exchanged token.
162+
:type exchange_scopes: Optional[list[str]]
163+
:return: The token response after exchange, or the original if exchange not configured.
164+
:rtype: TokenResponse
165+
"""
166+
if not input_token_response:
167+
return input_token_response
168+
169+
connection_name = exchange_connection or self._handler.obo_connection_name
170+
scopes = exchange_scopes or self._handler.scopes
171+
172+
# If OBO is not configured, return token as-is
173+
if not connection_name or not scopes:
174+
return input_token_response
175+
176+
# Check if token is exchangeable
177+
if not input_token_response.is_exchangeable():
178+
raise ValueError(
179+
str(ErrorResources.OboNotExchangeableToken.format(self._id))
180+
)
181+
182+
# Get the connection that supports OBO
183+
token_provider = self._connection_manager.get_connection(connection_name)
184+
if not token_provider:
185+
raise ValueError(
186+
str(
187+
ErrorResources.ResourceNotFound.format(
188+
f"Connection '{connection_name}'"
189+
)
190+
)
191+
)
192+
193+
# Perform the OBO exchange
194+
# Note: In Python, the acquire_token_on_behalf_of method is on the AccessTokenProviderBase
195+
token = await token_provider.acquire_token_on_behalf_of(
196+
scopes=scopes,
197+
user_assertion=input_token_response.token,
198+
)
199+
return TokenResponse(token=token) if token else None
200+
201+
def _create_token_response(self, context: TurnContext) -> TokenResponse:
202+
"""
203+
Creates a TokenResponse from the security token in the turn context identity.
204+
205+
:param context: The turn context for the current turn of conversation.
206+
:type context: TurnContext
207+
:return: A TokenResponse containing the extracted token.
208+
:rtype: TokenResponse
209+
:raises ValueError: If the identity doesn't have a security token.
210+
"""
211+
if not context.identity or not hasattr(context.identity, "security_token"):
212+
raise ValueError(
213+
f"Unexpected connector request - no security token found for handler: {self._id}"
214+
)
215+
216+
security_token = context.identity.security_token
217+
if not security_token:
218+
raise ValueError(
219+
f"Unexpected connector request - security token is None for handler: {self._id}"
220+
)
221+
222+
token_response = TokenResponse(token=security_token)
223+
224+
# Try to extract expiration and check if exchangeable
225+
try:
226+
# TODO: (connector) validate this decoding
227+
jwt_token = jwt.decode(security_token, options={"verify_signature": False})
228+
229+
# Set expiration if present
230+
if "exp" in jwt_token:
231+
# JWT exp is in Unix timestamp (seconds since epoch)
232+
expiration = datetime.fromtimestamp(jwt_token["exp"], tz=timezone.utc)
233+
# Convert to ISO 8601 format
234+
token_response.expiration = expiration.isoformat()
235+
236+
except Exception as ex:
237+
logger.warning(f"Failed to parse JWT token for handler {self._id}: {ex}")
238+
raise ex
239+
240+
return token_response

0 commit comments

Comments
 (0)