Repository navigation
Description
Summary
UserAuthorization normalizes the initial M365 Copilot Web sign-in request to the base msteams channel, but its later refresh path sends the raw composite msteams:COPILOT channel to Bot Framework GetToken.
The initial route auth gate succeeds, then Authorization.get_token() / exchange_token() refreshes through the raw channel partition and receives HTTP 404. Teams works because its incoming channel is already msteams; the failure is specific to M365 Copilot's composite channel.
Environment
microsoft-agents-hosting-core==1.1.0- Also reproduced from current
mainate24623e - Also present in the published
1.2.0.dev9wheel UserAuthorizationwith an Azure Bot OAuth connection- Custom engine agent invoked from M365 Copilot Web
Reproduction
- Configure a normal
UserAuthorizationhandler backed by an Azure Bot OAuth connection. - Register a message route with that handler in
auth_handlers. - Expose the agent in M365 Copilot Web so inbound activities use
channelId=msteams:COPILOT. - Complete silent SSO or interactive sign-in.
- Read or exchange the authenticated token in the route with
Authorization.get_token()orAuthorization.exchange_token().
Observed token-service sequence:
GetTokenOrSignInResource(channelId=msteams) -> 200
GetToken(channelId=msteams:COPILOT) -> 404
The first request uses the base channel because #392 calls get_conversation_reference(force_base_channel=True) in _OAuthFlow.begin_flow(). The refresh request does not.
Root cause
The remaining path is:
Authorization.exchange_token()
-> _UserAuthorization.get_refreshed_token()
-> _UserAuthorization._load_flow()
flow_state.channel_id = context.activity.channel_id
-> _OAuthFlow.__init__()
self._channel_id = flow_state.channel_id
-> _OAuthFlow.get_user_token()
UserToken.get_token(channel_id=self._channel_id)
_UserAuthorization._load_flow() records the raw activity channel in _FlowState, and _OAuthFlow reuses it for Bot Framework token operations. For Copilot Web this remains msteams:COPILOT.
Expected behavior
Bot Framework token operations should use the base msteams channel while OAuth flow storage, continuation activities, telemetry, and replies retain the original msteams:COPILOT channel.
The activity itself must not be mutated, because outbound streaming and replies need the original Copilot conversation reference.
Impact
A user can be signed in successfully at the route gate, but the handler's subsequent token read/refresh fails. The message turn then raises and M365 Copilot displays a generic failure instead of invoking the agent. Repeated attempts continue to fail because the successful base-channel token is queried through the wrong composite partition.
Proposed fix
Normalize only _OAuthFlow's operational token channel to the base segment while preserving _FlowState.channel_id unchanged:
self._channel_id = self._flow_state.channel_id.split(":", 1)[0]This keeps flow storage and continuation semantics surface-specific while making GetToken, token exchange, and sign-out consistent with the base-channel behavior already added to begin_flow() by #392.
A regression test should assert both halves:
UserToken.get_token(..., channel_id="msteams")flow.flow_state.channel_id == "msteams:COPILOT"
Validation
An application-level implementation of the same boundary was deployed and verified with consecutive M365 Copilot Web turns:
message_received channel=msteams:COPILOT
token exchange succeeded
agent invocation completed successfully
streamed reply finalized on channel=msteams:COPILOT
The previous GetToken 404 / failed-turn signature disappeared. No tenant IDs, application IDs, tokens, internal URLs, or conversation identifiers are included here.
Related work
#391described the composite-channel OAuth requirement.#392fixed the initialGetTokenOrSignInResourceand encoded-state path.microsoft/Agents-for-net#827tracks the corresponding composite-channel behavior in .NET.
Activity
Use base channel for OAuth token operations
rodrigobr-msft commentedon Jul 10, 2026
This is a good suggestion, but I think we can move this base channel selection logic to the
UserTokenclass, as is done in .NET. The change then would also have a greater impact sinceUserTokenis a dependency of_OAuthFlowCarvalh0XYZ commentedon Jul 10, 2026
Thanks for the guidance, Rodrigo Brandão (@rodrigobr-msft)! We moved the normalization to the
UserTokenboundary as suggested and tested it in our deployed custom-engine agent. Silent SSO is now working in both Teams and M365 Copilot.Before the change, a clean Copilot session could get stuck in a loop: token exchange stored the token under
msteams:COPILOT, while the replay looked for it undermsteams. One message generated ten exchange attempts and eventually hit the M365signInInforate limit.After resetting both token partitions, we started a new Copilot conversation. It completed silent SSO without showing a sign-in card or requiring a restart: one token exchange, one continuation replay, one successful token lookup, and one successful agent response. The activity and reply context stayed on
msteams:COPILOT; only the Bot Framework token operations usedmsteams. Teams continues to work as before.We updated #457 with the
UserTokenimplementation and regression coverage for all six token operations.And a slightly unusual credit: thanks to GPT-5.6, which stubbornly compared the Python and .NET SDKs until the extra colon finally confessed. 🤖
Thanks again for pointing us toward the cleaner boundary!