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/test/Microsoft.Teams.Core.UnitTests/Hosting/BotAuthenticationHandlerTests.cs

349lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4using System.Collections.Concurrent;
5using System.Net;
6using System.Reflection;
7using System.Security.Claims;
8using Microsoft.Extensions.Caching.Memory;
9using Microsoft.Extensions.Logging;
10using Microsoft.Extensions.Logging.Abstractions;
11using Microsoft.Identity.Abstractions;
12using Microsoft.Teams.Core.Hosting;
13using Microsoft.Teams.Core.Schema;
14using Moq;
15
16namespace Microsoft.Teams.Core.UnitTests.Hosting;
17
18public class BotAuthenticationHandlerTests
19{
20 private static readonly string TestAppId = Guid.NewGuid().ToString();
21 private static readonly string TestUserId = Guid.NewGuid().ToString();
22
23 private static (BotAuthenticationHandler handler, Mock<IAuthorizationHeaderProvider> mockProvider) CreateHandler()
24 {
25 Mock<IAuthorizationHeaderProvider> mockProvider = new();
26 mockProvider
27 .Setup(p => p.CreateAuthorizationHeaderAsync(
28 It.IsAny<IEnumerable<string>>(),
29 It.IsAny<AuthorizationHeaderProviderOptions>(),
30 It.IsAny<ClaimsPrincipal>(),
31 It.IsAny<CancellationToken>()))
32 .ReturnsAsync("Bearer fake-token");
33
34 mockProvider
35 .Setup(p => p.CreateAuthorizationHeaderForAppAsync(
36 It.IsAny<string>(),
37 It.IsAny<AuthorizationHeaderProviderOptions>(),
38 It.IsAny<CancellationToken>()))
39 .ReturnsAsync("Bearer fake-app-token");
40
41 BotAuthenticationHandler handler = new(
42 mockProvider.Object,
43 NullLogger<BotAuthenticationHandler>.Instance);
44
45 handler.InnerHandler = new StubInnerHandler();
46
47 return (handler, mockProvider);
48 }
49
50 private static HttpRequestMessage CreateAgenticRequest(string appId, string userId)
51 {
52 HttpRequestMessage request = new(HttpMethod.Post, "https://smba.trafficmanager.net/test/v3/conversations");
53 request.Options.Set(BotAuthenticationHandler.AgenticIdentityKey, new AgenticIdentity
54 {
55 AgenticAppId = appId,
56 AgenticUserId = userId,
57 });
58 return request;
59 }
60
61 private static MemoryCache GetPrivateCache(BotAuthenticationHandler handler)
62 {
63 FieldInfo field = typeof(BotAuthenticationHandler)
64 .GetField("_agenticPrincipalCache", BindingFlags.NonPublic | BindingFlags.Instance)!;
65 return (MemoryCache)field.GetValue(handler)!;
66 }
67
68 private static ConcurrentDictionary<string, SemaphoreSlim> GetPrivateLocks(BotAuthenticationHandler handler)
69 {
70 FieldInfo field = typeof(BotAuthenticationHandler)
71 .GetField("_agenticLocks", BindingFlags.NonPublic | BindingFlags.Instance)!;
72 return (ConcurrentDictionary<string, SemaphoreSlim>)field.GetValue(handler)!;
73 }
74
75 [Fact]
76 public async Task AgenticRequest_ReusesSameClaimsPrincipal_OnSubsequentCalls()
77 {
78 (BotAuthenticationHandler handler, Mock<IAuthorizationHeaderProvider> mockProvider) = CreateHandler();
79 List<ClaimsPrincipal> capturedPrincipals = [];
80
81 mockProvider
82 .Setup(p => p.CreateAuthorizationHeaderAsync(
83 It.IsAny<IEnumerable<string>>(),
84 It.IsAny<AuthorizationHeaderProviderOptions>(),
85 It.IsAny<ClaimsPrincipal>(),
86 It.IsAny<CancellationToken>()))
87 .Callback<IEnumerable<string>, AuthorizationHeaderProviderOptions, ClaimsPrincipal, CancellationToken>(
88 (_, _, principal, _) => capturedPrincipals.Add(principal))
89 .ReturnsAsync("Bearer fake-token");
90
91 using HttpMessageInvoker invoker = new(handler, disposeHandler: false);
92
93 await invoker.SendAsync(CreateAgenticRequest(TestAppId, TestUserId), CancellationToken.None);
94 await invoker.SendAsync(CreateAgenticRequest(TestAppId, TestUserId), CancellationToken.None);
95
96 Assert.Equal(2, capturedPrincipals.Count);
97 Assert.Same(capturedPrincipals[0], capturedPrincipals[1]);
98
99 handler.Dispose();
100 }
101
102 [Fact]
103 public async Task AgenticRequest_ConcurrentCallsForSameIdentity_AreSerialised()
104 {
105 (BotAuthenticationHandler handler, Mock<IAuthorizationHeaderProvider> mockProvider) = CreateHandler();
106
107 int concurrentCount = 0;
108 int maxConcurrent = 0;
109
110 mockProvider
111 .Setup(p => p.CreateAuthorizationHeaderAsync(
112 It.IsAny<IEnumerable<string>>(),
113 It.IsAny<AuthorizationHeaderProviderOptions>(),
114 It.IsAny<ClaimsPrincipal>(),
115 It.IsAny<CancellationToken>()))
116 .Returns<IEnumerable<string>, AuthorizationHeaderProviderOptions, ClaimsPrincipal, CancellationToken>(
117 async (_, _, _, ct) =>
118 {
119 int current = Interlocked.Increment(ref concurrentCount);
120 int snapshot;
121 do
122 {
123 snapshot = maxConcurrent;
124 } while (current > snapshot && Interlocked.CompareExchange(ref maxConcurrent, current, snapshot) != snapshot);
125
126 await Task.Delay(50, ct);
127 Interlocked.Decrement(ref concurrentCount);
128 return "Bearer fake-token";
129 });
130
131 using HttpMessageInvoker invoker = new(handler, disposeHandler: false);
132
133 Task[] tasks = Enumerable.Range(0, 5)
134 .Select(_ => invoker.SendAsync(CreateAgenticRequest(TestAppId, TestUserId), CancellationToken.None))
135 .ToArray();
136
137 await Task.WhenAll(tasks);
138
139 Assert.Equal(1, maxConcurrent);
140
141 handler.Dispose();
142 }
143
144 [Fact]
145 public async Task AgenticRequest_DifferentIdentities_RunConcurrently()
146 {
147 (BotAuthenticationHandler handler, Mock<IAuthorizationHeaderProvider> mockProvider) = CreateHandler();
148
149 int concurrentCount = 0;
150 int maxConcurrent = 0;
151 TaskCompletionSource allStarted = new();
152
153 mockProvider
154 .Setup(p => p.CreateAuthorizationHeaderAsync(
155 It.IsAny<IEnumerable<string>>(),
156 It.IsAny<AuthorizationHeaderProviderOptions>(),
157 It.IsAny<ClaimsPrincipal>(),
158 It.IsAny<CancellationToken>()))
159 .Returns<IEnumerable<string>, AuthorizationHeaderProviderOptions, ClaimsPrincipal, CancellationToken>(
160 async (_, _, _, ct) =>
161 {
162 int current = Interlocked.Increment(ref concurrentCount);
163 int snapshot;
164 do
165 {
166 snapshot = maxConcurrent;
167 } while (current > snapshot && Interlocked.CompareExchange(ref maxConcurrent, current, snapshot) != snapshot);
168
169 // Wait until all tasks have entered the critical section
170 if (current >= 3)
171 {
172 allStarted.TrySetResult();
173 }
174
175 await allStarted.Task.WaitAsync(TimeSpan.FromSeconds(5), ct);
176 Interlocked.Decrement(ref concurrentCount);
177 return "Bearer fake-token";
178 });
179
180 using HttpMessageInvoker invoker = new(handler, disposeHandler: false);
181
182 Task[] tasks = Enumerable.Range(0, 3)
183 .Select(i => invoker.SendAsync(
184 CreateAgenticRequest(Guid.NewGuid().ToString(), Guid.NewGuid().ToString()),
185 CancellationToken.None))
186 .ToArray();
187
188 await Task.WhenAll(tasks);
189
190 Assert.True(maxConcurrent >= 2, $"Expected concurrent execution for different identities but maxConcurrent was {maxConcurrent}");
191
192 handler.Dispose();
193 }
194
195 [Fact]
196 public async Task AgenticRequest_CacheEviction_RemovesLockEntry_AndSubsequentCallSucceeds()
197 {
198 // After a principal is evicted from the cache, the matching lock entry must be
199 // removed from _agenticLocks so the dictionary stays bounded. A subsequent call
200 // for the same identity must still succeed (creating a fresh semaphore + principal)
201 // without ever throwing ObjectDisposedException.
202 (BotAuthenticationHandler handler, Mock<IAuthorizationHeaderProvider> mockProvider) = CreateHandler();
203 using HttpMessageInvoker invoker = new(handler, disposeHandler: false);
204
205 // First call — populates both the cache entry and the semaphore.
206 await invoker.SendAsync(CreateAgenticRequest(TestAppId, TestUserId), CancellationToken.None);
207
208 ConcurrentDictionary<string, SemaphoreSlim> locks = GetPrivateLocks(handler);
209 MemoryCache cache = GetPrivateCache(handler);
210
211 Assert.NotEmpty(locks);
212
213 // Force eviction by compacting the entire cache.
214 cache.Compact(1.0);
215
216 // The post-eviction callback runs on the thread pool; wait briefly for it to remove
217 // the lock entry. Bounded by 2s to fail fast if the callback is not wired up.
218 bool removed = SpinWait.SpinUntil(() => locks.IsEmpty, TimeSpan.FromSeconds(2));
219 Assert.True(removed, "Expected the lock entry to be removed from _agenticLocks after cache eviction.");
220
221 // Second call for the same identity — must NOT throw ObjectDisposedException
222 // and must repopulate both the cache and the lock dictionary.
223 await invoker.SendAsync(CreateAgenticRequest(TestAppId, TestUserId), CancellationToken.None);
224
225 Assert.NotEmpty(locks);
226
227 handler.Dispose();
228 }
229
230 [Fact]
231 public async Task AgenticRequest_AfterCacheEviction_CreatesNewPrincipal()
232 {
233 (BotAuthenticationHandler handler, Mock<IAuthorizationHeaderProvider> mockProvider) = CreateHandler();
234 List<ClaimsPrincipal> capturedPrincipals = [];
235
236 mockProvider
237 .Setup(p => p.CreateAuthorizationHeaderAsync(
238 It.IsAny<IEnumerable<string>>(),
239 It.IsAny<AuthorizationHeaderProviderOptions>(),
240 It.IsAny<ClaimsPrincipal>(),
241 It.IsAny<CancellationToken>()))
242 .Callback<IEnumerable<string>, AuthorizationHeaderProviderOptions, ClaimsPrincipal, CancellationToken>(
243 (_, _, principal, _) => capturedPrincipals.Add(principal))
244 .ReturnsAsync("Bearer fake-token");
245
246 using HttpMessageInvoker invoker = new(handler, disposeHandler: false);
247
248 // First call — populates cache.
249 await invoker.SendAsync(CreateAgenticRequest(TestAppId, TestUserId), CancellationToken.None);
250
251 // Evict everything.
252 MemoryCache cache = GetPrivateCache(handler);
253 cache.Compact(1.0);
254
255 // Second call — should create a new ClaimsPrincipal since cache was evicted.
256 await invoker.SendAsync(CreateAgenticRequest(TestAppId, TestUserId), CancellationToken.None);
257
258 Assert.Equal(2, capturedPrincipals.Count);
259 Assert.NotSame(capturedPrincipals[0], capturedPrincipals[1]);
260
261 handler.Dispose();
262 }
263
264 [Fact]
265 public async Task AgenticRequest_ConcurrentCallsDuringCacheEviction_DoNotThrow()
266 {
267 (BotAuthenticationHandler handler, Mock<IAuthorizationHeaderProvider> mockProvider) = CreateHandler();
268 SemaphoreSlim gate = new(0, 1);
269
270 mockProvider
271 .Setup(p => p.CreateAuthorizationHeaderAsync(
272 It.IsAny<IEnumerable<string>>(),
273 It.IsAny<AuthorizationHeaderProviderOptions>(),
274 It.IsAny<ClaimsPrincipal>(),
275 It.IsAny<CancellationToken>()))
276 .Returns<IEnumerable<string>, AuthorizationHeaderProviderOptions, ClaimsPrincipal, CancellationToken>(
277 async (_, _, _, ct) =>
278 {
279 // Signal that we are inside the critical section, then wait.
280 gate.Release();
281 await Task.Delay(100, ct);
282 return "Bearer fake-token";
283 });
284
285 using HttpMessageInvoker invoker = new(handler, disposeHandler: false);
286
287 string appId = Guid.NewGuid().ToString();
288 string userId = Guid.NewGuid().ToString();
289
290 // Start a call that will hold the semaphore.
291 Task firstCall = invoker.SendAsync(CreateAgenticRequest(appId, userId), CancellationToken.None);
292
293 // Wait until it's inside the critical section.
294 await gate.WaitAsync();
295
296 // Evict the cache entry while the semaphore is held.
297 MemoryCache cache = GetPrivateCache(handler);
298 cache.Compact(1.0);
299
300 // The first call should complete without ObjectDisposedException.
301 await firstCall;
302
303 // A follow-up call should also work.
304 // Reset mock to return immediately.
305 mockProvider
306 .Setup(p => p.CreateAuthorizationHeaderAsync(
307 It.IsAny<IEnumerable<string>>(),
308 It.IsAny<AuthorizationHeaderProviderOptions>(),
309 It.IsAny<ClaimsPrincipal>(),
310 It.IsAny<CancellationToken>()))
311 .ReturnsAsync("Bearer fake-token");
312
313 await invoker.SendAsync(CreateAgenticRequest(appId, userId), CancellationToken.None);
314
315 handler.Dispose();
316 }
317
318 [Fact]
319 public void Dispose_CleansUpSemaphoresAndCache()
320 {
321 (BotAuthenticationHandler handler, _) = CreateHandler();
322 ConcurrentDictionary<string, SemaphoreSlim> locks = GetPrivateLocks(handler);
323
324 // Manually add some semaphores to simulate cached agentic identities.
325 locks.TryAdd("key1", new SemaphoreSlim(1, 1));
326 locks.TryAdd("key2", new SemaphoreSlim(1, 1));
327
328 SemaphoreSlim s1 = locks["key1"];
329 SemaphoreSlim s2 = locks["key2"];
330
331 handler.Dispose();
332
333 Assert.Empty(locks);
334 // Disposed semaphores throw on WaitAsync.
335 Assert.Throws<ObjectDisposedException>(() => s1.Wait(0));
336 Assert.Throws<ObjectDisposedException>(() => s2.Wait(0));
337 }
338
339 /// <summary>
340 /// Stub inner handler that returns 200 OK for all requests.
341 /// </summary>
342 private class StubInnerHandler : HttpMessageHandler
343 {
344 protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
345 {
346 return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
347 }
348 }
349}
350