microsoft/teams.net
Publicmirrored fromhttps://github.com/microsoft/teams.netAvailable
core/src/Microsoft.Teams.Core/Hosting/BotAuthenticationHandler.cs
234lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | using System.Collections.Concurrent; |
| 5 | using System.Diagnostics; |
| 6 | using System.IdentityModel.Tokens.Jwt; |
| 7 | using System.Net.Http.Headers; |
| 8 | using System.Security.Claims; |
| 9 | using Microsoft.Extensions.Caching.Memory; |
| 10 | using Microsoft.Extensions.Logging; |
| 11 | using Microsoft.Extensions.Options; |
| 12 | using Microsoft.Identity.Abstractions; |
| 13 | using Microsoft.Identity.Web; |
| 14 | using Microsoft.Teams.Core.Diagnostics; |
| 15 | using Microsoft.Teams.Core.Schema; |
| 16 | |
| 17 | namespace 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> |
| 30 | internal 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 | |