microsoft/teams.net

Public

mirrored fromhttps://github.com/microsoft/teams.netAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
fix/msal-agentic-cache

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs

234lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.Collections.Concurrent;
5using System.Diagnostics;
6using System.IdentityModel.Tokens.Jwt;
7using System.Net.Http.Headers;
8using System.Security.Claims;
9using Microsoft.Extensions.Caching.Memory;
10using Microsoft.Extensions.Logging;
11using Microsoft.Extensions.Options;
12using Microsoft.Identity.Abstractions;
13using Microsoft.Identity.Web;
14using Microsoft.Teams.Core.Diagnostics;
15using Microsoft.Teams.Core.Schema;
16
17namespace Microsoft.Teams.Core.Hosting;
18
19/// <summary>
20/// HTTP message handler that automatically acquires and attaches authentication tokens
21/// for Bot Framework API calls. Supports both app-only and agentic (user-delegated) token acquisition.
22/// </summary>
23/// <remarks>
24/// Initializes a new instance of the <see cref="BotAuthenticationHandler"/> class.
25/// </remarks>
26/// <param name="authorizationHeaderProvider">The authorization header provider for acquiring tokens.</param>
27/// <param name="logger">The logger instance.</param>
28/// <param name="authenticationOptionsName">The name of the MSAL configuration options to use for token acquisition. Defaults to "AzureAd".</param>
29/// <param name="managedIdentityOptions">Optional managed identity options monitor. When the named entry matching <paramref name="authenticationOptionsName"/> has a non-empty <c>UserAssignedClientId</c>, tokens are acquired via the IMDS endpoint as the configured managed identity instead of via the app-credentials flow.</param>
30internal sealed class BotAuthenticationHandler(
31 IAuthorizationHeaderProvider authorizationHeaderProvider,
32 ILogger<BotAuthenticationHandler> logger,
33 string? authenticationOptionsName = null,
34 IOptionsMonitor<ManagedIdentityOptions>? managedIdentityOptions = null) : DelegatingHandler
35{
36 private const string AgenticScope = "https://botapi.skype.com/.default";
37 private const string BotAppScope = "https://api.botframework.com/.default";
38 private static readonly TimeSpan _agenticPrincipalSlidingExpiry = TimeSpan.FromHours(1);
39
40 private readonly IAuthorizationHeaderProvider _authorizationHeaderProvider = authorizationHeaderProvider ?? throw new ArgumentNullException(nameof(authorizationHeaderProvider));
41 private readonly ILogger<BotAuthenticationHandler> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
42 private readonly IOptionsMonitor<ManagedIdentityOptions>? _managedIdentityOptions = managedIdentityOptions;
43
44 // Cache ClaimsPrincipal per agentic identity (bounded + sliding expiry) so MSAL can reuse
45 // the account ID populated after the first ROPC call for silent token acquisition on subsequent calls.
46 private readonly MemoryCache _agenticPrincipalCache = new(new MemoryCacheOptions { SizeLimit = 10_000 });
47 // Per-key semaphores to prevent concurrent requests from mutating the same ClaimsPrincipal simultaneously.
48 private readonly ConcurrentDictionary<string, SemaphoreSlim> _agenticLocks = new();
49 // Removes the lock entry when its principal is evicted from the cache so _agenticLocks stays bounded.
50 // The semaphore itself is NOT disposed here — an in-flight call may still hold it and must be able
51 // to call Release() without ObjectDisposedException; GC reclaims it once all references are gone.
52 private static readonly PostEvictionDelegate _removeAgenticLockOnEviction =
53 static (key, _, _, state) =>
54 {
55 if (state is ConcurrentDictionary<string, SemaphoreSlim> locks && key is string k)
56 {
57 locks.TryRemove(k, out _);
58 }
59 };
60 private static readonly Action<ILogger, string, Exception?> _logAgenticToken =
61 LoggerMessage.Define<string>(LogLevel.Debug, new(2), "Acquiring agentic token for AgenticAppId {AgenticAppId}");
62 private static readonly Action<ILogger, string, Exception?> _logAppOnlyToken =
63 LoggerMessage.Define<string>(LogLevel.Debug, new(3), "Acquiring app-only token for scope: {Scope}");
64 private static readonly Action<ILogger, string, Exception?> _logTokenClaims =
65 LoggerMessage.Define<string>(LogLevel.Trace, new(4), "Acquired token claims:{Claims}");
66 private static readonly Action<ILogger, string, Exception?> _logInvalidAgenticUserId =
67 LoggerMessage.Define<string>(LogLevel.Warning, new(5), "Invalid AgenticUserId '{AgenticUserId}'; falling back to app-only token.");
68 private static readonly Action<ILogger, Exception?> _logTokenParseFailure =
69 LoggerMessage.Define(LogLevel.Warning, new(6), "Failed to parse JWT token for trace logging.");
70
71 /// <summary>
72 /// Key used to store the agentic identity in HttpRequestMessage options.
73 /// </summary>
74 public static readonly HttpRequestOptionsKey<AgenticIdentity?> AgenticIdentityKey = new("AgenticIdentity");
75
76 /// <inheritdoc/>
77 protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
78 {
79 request.Options.TryGetValue(AgenticIdentityKey, out AgenticIdentity? agenticIdentity);
80
81 string token = await GetAuthorizationHeaderAsync(agenticIdentity, cancellationToken).ConfigureAwait(false);
82
83 string tokenValue = token.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
84 ? token["Bearer ".Length..]
85 : token;
86
87 LogTokenClaims(tokenValue);
88
89 request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenValue);
90
91 return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
92 }
93
94 /// <summary>
95 /// Gets an authorization header for Bot Framework API calls.
96 /// Supports both app-only and agentic (user-delegated) token acquisition.
97 /// </summary>
98 /// <param name="agenticIdentity">Optional agentic identity for user-delegated token acquisition. If not provided, acquires an app-only token.</param>
99 /// <param name="cancellationToken">Cancellation token.</param>
100 /// <returns>The authorization header value.</returns>
101 private async Task<string> GetAuthorizationHeaderAsync(AgenticIdentity? agenticIdentity, CancellationToken cancellationToken)
102 {
103 string optionsName = authenticationOptionsName ?? BotConfig.DefaultSectionName;
104 using Activity? span = Telemetry.Source.StartActivity(Telemetry.Spans.AuthOutbound, ActivityKind.Client);
105
106
107 try
108 {
109 AuthorizationHeaderProviderOptions options = new()
110 {
111 AcquireTokenOptions = new AcquireTokenOptions()
112 {
113 AuthenticationOptionsName = optionsName,
114 }
115 };
116
117 // Conditionally apply ManagedIdentity configuration if registered
118 if (_managedIdentityOptions is not null)
119 {
120 ManagedIdentityOptions miOptions = _managedIdentityOptions.Get(optionsName);
121
122 if (!string.IsNullOrEmpty(miOptions.UserAssignedClientId))
123 {
124 _logger.InferringUserAssignedManagedIdentity(miOptions.UserAssignedClientId);
125 options.AcquireTokenOptions.ManagedIdentity = miOptions;
126 span?.SetTag(Telemetry.Tags.AuthFlow, "managed_identity");
127 }
128 }
129
130 if (agenticIdentity is not null &&
131 !string.IsNullOrEmpty(agenticIdentity.AgenticAppId) &&
132 !string.IsNullOrEmpty(agenticIdentity.AgenticUserId))
133 {
134 span?.SetTag(Telemetry.Tags.AuthScope, AgenticScope);
135 _logAgenticToken(_logger, agenticIdentity.AgenticAppId, null);
136
137 if (!Guid.TryParse(agenticIdentity.AgenticUserId, out Guid agenticUserGuid))
138 {
139 _logInvalidAgenticUserId(_logger, agenticIdentity.AgenticUserId, null);
140 }
141 else
142 {
143 span?.SetTag(Telemetry.Tags.AuthFlow, "agentic");
144 options.WithAgentUserIdentity(agenticIdentity.AgenticAppId, agenticUserGuid);
145
146 // Reuse a cached ClaimsPrincipal so MSAL's silent flow can look up
147 // the account ID populated after the first ROPC token exchange.
148 // Without this, ClaimsPrincipal is null on every call and MSAL
149 // always falls through to a network round-trip to Entra.
150 // A per-key semaphore serialises concurrent requests for the same identity
151 // to prevent unsynchronised mutation of the shared ClaimsPrincipal.
152 string cacheKey = GetAgenticCacheKey(agenticIdentity.AgenticAppId, agenticUserGuid);
153 SemaphoreSlim semaphore = _agenticLocks.GetOrAdd(cacheKey, _ => new SemaphoreSlim(1, 1));
154 await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
155 try
156 {
157 if (!_agenticPrincipalCache.TryGetValue(cacheKey, out ClaimsPrincipal? principal) || principal is null)
158 {
159 principal = new ClaimsPrincipal();
160 _agenticPrincipalCache.Set(cacheKey, principal, new MemoryCacheEntryOptions
161 {
162 SlidingExpiration = _agenticPrincipalSlidingExpiry,
163 Size = 1,
164 }.RegisterPostEvictionCallback(_removeAgenticLockOnEviction, _agenticLocks));
165 }
166
167 string token = await _authorizationHeaderProvider.CreateAuthorizationHeaderAsync([AgenticScope], options, principal, cancellationToken).ConfigureAwait(false);
168 return token;
169 }
170 finally
171 {
172 semaphore.Release();
173 }
174 }
175 }
176 span?.SetTag(Telemetry.Tags.AuthScope, BotAppScope);
177 _logAppOnlyToken(_logger, BotAppScope, null);
178 // Don't overwrite a more specific flow (managed_identity) already set above.
179 if (span is not null && !span.TagObjects.Any(t => t.Key == Telemetry.Tags.AuthFlow))
180 {
181 span.SetTag(Telemetry.Tags.AuthFlow, "app_only");
182 }
183 string appToken = await _authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync(BotAppScope, options, cancellationToken).ConfigureAwait(false);
184
185
186 return appToken;
187 }
188 catch (Exception ex)
189 {
190 span.RecordException(ex);
191 throw;
192 }
193 }
194
195 private static string GetAgenticCacheKey(string agenticAppId, Guid agenticUserGuid) =>
196 $"{agenticAppId}:{agenticUserGuid:D}";
197
198 private void LogTokenClaims(string token)
199 {
200 if (!_logger.IsEnabled(LogLevel.Trace))
201 {
202 return;
203 }
204
205
206 try
207 {
208 JwtSecurityToken jwtToken = new(token);
209 string claims = Environment.NewLine + string.Join(Environment.NewLine, jwtToken.Claims.Select(c => $" {c.Type}: {c.Value}"));
210 _logTokenClaims(_logger, claims, null);
211 }
212 catch (ArgumentException ex)
213 {
214 _logTokenParseFailure(_logger, ex);
215 }
216 }
217
218 /// <inheritdoc/>
219 protected override void Dispose(bool disposing)
220 {
221 if (disposing)
222 {
223 _agenticPrincipalCache.Dispose();
224 foreach (SemaphoreSlim s in _agenticLocks.Values)
225 {
226 s.Dispose();
227 }
228
229 _agenticLocks.Clear();
230 }
231
232 base.Dispose(disposing);
233 }
234}
235