Skip to content

[Bug] UserAuthorization refresh sends composite Copilot channel to GetToken #456

Closed

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 main at e24623e
  • Also present in the published 1.2.0.dev9 wheel
  • UserAuthorization with an Azure Bot OAuth connection
  • Custom engine agent invoked from M365 Copilot Web

Reproduction

  1. Configure a normal UserAuthorization handler backed by an Azure Bot OAuth connection.
  2. Register a message route with that handler in auth_handlers.
  3. Expose the agent in M365 Copilot Web so inbound activities use channelId=msteams:COPILOT.
  4. Complete silent SSO or interactive sign-in.
  5. Read or exchange the authenticated token in the route with Authorization.get_token() or Authorization.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

  • #391 described the composite-channel OAuth requirement.
  • #392 fixed the initial GetTokenOrSignInResource and encoded-state path.
  • microsoft/Agents-for-net#827 tracks the corresponding composite-channel behavior in .NET.

Activity

  1. added a commit that references this issue on Jul 9, 2026
    d1818ee
  2. rodrigobr-msft commented on Jul 10, 2026

    @rodrigobr-msft
    Contributor

    This is a good suggestion, but I think we can move this base channel selection logic to the UserToken class, as is done in .NET. The change then would also have a greater impact since UserToken is a dependency of _OAuthFlow

  3. Carvalh0XYZ commented on Jul 10, 2026

    @Carvalh0XYZ
    ContributorAuthor

    Thanks for the guidance, Rodrigo Brandão (@rodrigobr-msft)! We moved the normalization to the UserToken boundary 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 under msteams. One message generated ten exchange attempts and eventually hit the M365 signInInfo rate 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 used msteams. Teams continues to work as before.

    We updated #457 with the UserToken implementation 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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    BugSomething isn't working

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions