Skip to content

[M365 Agents SDK] How to overcome silent SSO with botframework.com Token Service

You might have started to get your hands on the M365 Agents SDK. If so, maybe you tried to use the Autosignin feature which lets users leverage API such as Microsoft Graph in user context. If everything went fine (besides Entra ID config but that will be for another blog post 😉), well good for you! Otherwise, you probably faced issues and this short article could help you getting through this!

Context

When using the M365 Agents SDK, a key point is authentication. First for using the Azure Bot Service that will let you call your solution from channels such as Microsoft Teams. Then for optionally consuming an API like Graph.

But as the mentioned SDK is recent (in 2025), there're some caveats when it's about "getting started" with it. Samples & MS Learn can help, but they're not covering everything from my perspective.

As I was trying to get a token from an OAuth connection declared in my Azure Bot Service, in order to query Graph API, I struggled with botframework.com that is the gateway to get a first token (the one that "belongs" to the agent), with which I'm supposed to query for a second token, this time delegated / user context one, that I would use for Graph calls.

My configuration was the following:

  • OAuth connection name: "graphsecret" (used for both handlers which is ok)

For this article, I chose to use ClientSecret AuthType for using Azure Bot Service because it's about local debugging on Teams. In production environment, you should use Federated Identity Credentials or Managed Identity.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
{
  "AgentApplication": {
    "StartTypingTimer": false,
    "RemoveRecipientMention": false,
    "NormalizeMentions": false,

    "UserAuthorization": {
      "Default": "auto",
      "AutoSignin": true,
      "Handlers": {
        "auto": {
          "Settings": {
            "AzureBotOAuthConnectionName": "graphsecret"
          }
        },
        "me": {
          "Settings": {
            "AzureBotOAuthConnectionName": "graphsecret"
          }
        }
      }
    }
  },

  "TokenValidation": {
    "Audiences": [
      "{{BOT_ID}}"
    ],
    "TenantId": "{{BOT_TENANT_ID}}"
  },

  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.Agents": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "Connections": {
    "BotServiceConnection": {
      "Assembly": "Microsoft.Agents.Authentication.Msal",
      "Type": "MsalAuth",
      "Settings": {
        "AuthType": "ClientSecret",
        "ClientId": "{{BOT_ID}}",
        "ClientSecret": "{{SECRET_BOT_PASSWORD}}",
        "AuthorityEndpoint": "https://login.microsoftonline.com/{{BOT_TENANT_ID}}",
        "TenantId": "{{BOT_TENANT_ID}}",
        "Scopes": [
          "https://api.botframework.com/.default"
        ]
      }
    }
  },
  "ConnectionsMap": [
    {
      "ServiceUrl": "*",
      "Connection": "BotServiceConnection"
    }
  ]
}

Then I had this configuration for the Azure Bot Service, including the "graphsecret" OAuth connection setting:

alt text

Below the Entra ID app config (feel free to right-click => Open the image in a new tab when necessary):

alt text

alt text

alt text

alt text

alt text

Finally, the code that triggers the SSO:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
using MyM365Agent;
using MyM365Agent.Bot;
using Microsoft.Agents.Hosting.AspNetCore;
using Microsoft.Agents.Storage;
using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.State;

// ...

builder.AddAgent<AuthAgent>();
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// This comes from the "autosign-in" sample:
// https://github.com/microsoft/Agents/blob/main/samples/dotnet/auto-signin/AuthAgent.cs
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.Agents.Builder;
using Microsoft.Agents.Builder.App;
using Microsoft.Agents.Builder.App.UserAuth;
using Microsoft.Agents.Builder.State;
using Microsoft.Agents.Builder.UserAuth;
using Microsoft.Agents.Core.Models;
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;

namespace AutoSignIn;
public class AuthAgent : AgentApplication
{
    /// <summary>
    /// Default Sign In Name
    /// </summary>
    private readonly string _defaultDisplayName = "Unknown User";

    /// <summary>
    /// Describes the agent registration for the Authorization Agent
    /// This agent will handle the sign-in and sign-out OAuth processes for a user.
    /// </summary>
    /// <param name="options">AgentApplication Configuration objects to configure and setup the Agent Application</param>
    public AuthAgent(AgentApplicationOptions options) : base(options)
    {
        OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync);

        OnMessage("-me", OnMe, autoSignInHandlers: ["me"]);

        OnActivity(ActivityTypes.Message, OnMessageAsync, rank: RouteRank.Last);

        UserAuthorization.OnUserSignInFailure(OnUserSignInFailure);
    }

    /// <summary>
    /// This method is called to handle the conversation update event when a new member is added to or removed from the conversation.
    /// </summary>
    /// <param name="turnContext"><see cref="ITurnContext"/></param>
    /// <param name="turnState"><see cref="ITurnState"/></param>
    /// <param name="cancellationToken"><see cref="CancellationToken"/></param>
    private async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
    {
        // ...
    }

    /// Handles -me, using a different OAuthConnection to show Per-Route OAuth. 
    /// </summary>
    /// <param name="turnContext"><see cref="ITurnContext"/></param>
    /// <param name="turnState"><see cref="ITurnState"/></param>
    /// <param name="cancellationToken"><see cref="CancellationToken"/></param>
    private async Task OnMe(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
    {    
        var displayName = await GetDisplayName(turnContext);
        var graphInfo = await GetGraphInfo(turnContext, "me");

        // Just to verify "auto" handler setup.  This wouldn't be needed in a production Agent and here just to verify sample setup.
        if (displayName.Equals(_defaultDisplayName) || graphInfo == null)
        {
            await turnContext.SendActivityAsync($"Failed to get information from handlers '{UserAuthorization.DefaultHandlerName}' and/or 'me'. \nDid you update the scope correctly in Azure bot Service?. If so type in -signout to force signout the current user", cancellationToken: cancellationToken);
            return;
        }

        var meInfo = $"Name: {displayName}\r\nJob Title: {graphInfo["jobTitle"]!.GetValue<string>()}\r\nEmail: {graphInfo["mail"]!.GetValue<string>()}";
        await turnContext.SendActivityAsync(meInfo, cancellationToken: cancellationToken);
    }

    /// <summary>
    /// Handles general message loop. 
    /// </summary>
    /// <param name="turnContext"><see cref="ITurnContext"/></param>
    /// <param name="turnState"><see cref="ITurnState"/></param>
    /// <param name="cancellationToken"><see cref="CancellationToken"/></param>
    /// <returns></returns>
    private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
    {
        string displayName = await GetDisplayName(turnContext);
        if (displayName.Equals(_defaultDisplayName))
        {
            // Handle error response from Graph API
            await turnContext.SendActivityAsync($"Failed to get user information from Graph API \nDid you update the scope correctly in Azure bot Service?. If so type in -signout to force signout the current user", cancellationToken: cancellationToken);
            return;
        }

        // Now Echo back what was said with your display name. 
        await turnContext.SendActivityAsync($"**{displayName} said:** {turnContext.Activity.Text}", cancellationToken: cancellationToken);
    }

    /// <summary>
    /// This method is called when the sign-in process fails with an error indicating why . 
    /// </summary>
    /// <param name="turnContext"></param>
    /// <param name="turnState"></param>
    /// <param name="handlerName"></param>
    /// <param name="response"></param>
    /// <param name="initiatingActivity"></param>
    /// <param name="cancellationToken"></param>
    private async Task OnUserSignInFailure(ITurnContext turnContext, ITurnState turnState, string handlerName, SignInResponse response, IActivity initiatingActivity, CancellationToken cancellationToken)
    {
        await turnContext.SendActivityAsync($"Sign In: Failed to login to '{handlerName}': {response.Cause}/{response.Error!.Message}", cancellationToken: cancellationToken);
    }

    /// <summary>
    /// Gets the display name of the user from the Graph API using the access token.
    /// </summary>
    private async Task<string> GetDisplayName(ITurnContext turnContext)
    {
        string displayName = _defaultDisplayName;
        var graphInfo = await GetGraphInfo(turnContext, UserAuthorization.DefaultHandlerName);
        if (graphInfo != null)
        {
            displayName = graphInfo!["displayName"]!.GetValue<string>();
        }
        return displayName;
    }

    private async Task<JsonNode> GetGraphInfo(ITurnContext turnContext, string handleName)
    {
        string accessToken = await UserAuthorization.GetTurnTokenAsync(turnContext, handleName);
        string graphApiUrl = $"https://graph.microsoft.com/v1.0/me";
        try
        {
            using HttpClient client = new();
            client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
            HttpResponseMessage response = await client.GetAsync(graphApiUrl);
            if (response.IsSuccessStatusCode)
            {
                var content = await response.Content.ReadAsStringAsync();
                return JsonNode.Parse(content)!;
            }
        }
        catch (Exception ex)
        {
            // Handle error response from Graph API
            System.Diagnostics.Trace.WriteLine($"Error getting display name: {ex.Message}");
        }
        return null!;
    }
}

As the Autosignin feature is enabled, when you send a message, the OnMessageAsync method is triggered and a call to Graph is supposed to be done to get sender's information (with the GetGraphInfo method called by GetDisplayName).

But in order to perform this query, a token must be obtained first. This one is get through the OAuth connection declared in the Azure Bot Service (the "graphsecret" one, remember?), so the M365 Agents SDK is querying api.botframework.com Token Service first, before triggering any message handler. The query looks like this:

https://api.botframework.com/api/usertoken/GetTokenOrSignInResource?userId=[TEAMS_USER_ID]&connectionName=graphsecret&channelId=msteams&state=[JWT_TOKEN]%3D%3D

The issue

But here's the thing: provided as-is, the request will fail and return the following error in both Microsoft Teams and Azure Bot web chat:

The Token Service returned an unexpected response for GetTokenOrSignInResource: '(401) Unauthorized'.

The OnUserSignInFailure method will be triggered and if you read the content of response.Error.Message property, you'll get the following message:

The Access Token created to respond to a request from the agent was rejected by the remote endpoint. This can be caused by the incorrect connection configuration. Please verify that configuration against the expected configuration.

... Well, that doesn't really sound explicit right?

At first, I thought it was a configuration issue regarding Azure Bot Service, OAuth connection, Entra ID, project's appsettings.json file,... I spent many hours trying to understand what was missing. Of course, this led me to search for legacy documentation involving Bot Framework SDK projects, without any clue that could give me a hint (or I missed it).

I contacted one of my partners in crime Yves Harbersaat to ask him to try any sample with Graph API involved, with the appropriate configuration. He showed me that it worked for him and that surprised me. So I had a closer look at his configuration (Entra, Azure, OAuth,...) without noticing any difference.

The "solution"

Then I declared an issue to the .NET Agents SDK, adding as much info as I could to guide dev team. They eventually found the source of the problem, which was related to my current geographic location. I had the following (undocumented) property missing in the appsettings.json file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
  "AgentApplication": {
    // ...
    "UserAuthorization": {
      // ...
      }
  },

  "TokenValidation": {
    // ...
  },

  // ...
  "Connections": {
    "BotServiceConnection": {
      // ...
    }
  },
  "ConnectionsMap": [
    {
      "ServiceUrl": "*",
      "Connection": "BotServiceConnection"
    }
  ],
  "RestChannelServiceClientFactory": {
    "TokenServiceEndpoint": "https://europe.api.botframework.com"
  },
}

The RestChannelServiceClientFactory needed to be provided... With the api.botframework.com target region URL (in my case in Europe). And Yves didn't have to provide this because... He's in Switzerland.

alt text

And as you can imagine, of course it worked after providing this. I was able to query Graph, switch between handlers...

An important thing to add anyway: even if you have added API scope to both the Entra ID app and in the scopes of the OAuth connection, it has to be consented first. Otherwise, the silent SSO won't work and the provided samples with Autosignin enabled will fail. I think that the solution could be to implement a OAuth prompt dialog in the process, in order to allow user to consent at the right time.

Conclusion

One thing that I learnt lessons from is to not wait too long when you're stuck at some point. I regret no having called for help soon enough because I wouldn't wasted hours trying to figure out what did I miss where in the end, the issue didn't depend on me. Don't be ashamed to ask for help or to admit that you don't know, even if you were 100% sure to know how stuff works before everything collapse.

I would like to thank Yves for his help regarding the tests and Antti Koskela as one of my other partners in crime who inspired me to do this article and which has started to write cool stuff about the M365 Agents SDK, check it out 👇