From 02955199f6344c8928dc0601ed5462a40268329c Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 12:22:54 +0200 Subject: [PATCH] feat(01-04): MsalClientFactory with per-clientId PCA and MsalCacheHelper - Creates one IPublicClientApplication per ClientId (never shared) - Persists token cache to configurable directory (default: %AppData%\SharepointToolbox\auth\msal_{clientId}.cache) - SemaphoreSlim(1,1) prevents duplicate creation under concurrent calls - CacheDirectory property exposed for test injection - 4 unit tests: same-instance, different-instance, concurrent, AppData path --- .../Auth/MsalClientFactoryTests.cs | 74 ++++++++++++++++++- .../Infrastructure/Auth/MsalClientFactory.cs | 69 +++++++++++++++++ 2 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs diff --git a/SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs b/SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs index 653fda0..7d57a86 100644 --- a/SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs +++ b/SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs @@ -1,7 +1,75 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using SharepointToolbox.Infrastructure.Auth; +using Xunit; + namespace SharepointToolbox.Tests.Auth; -public class MsalClientFactoryTests +[Trait("Category", "Unit")] +public class MsalClientFactoryTests : IDisposable { - [Fact(Skip = "Wave 0 stub — implemented in plan 01-04")] - public void CreateClient_Returns_ConfiguredPublicClientApplication() { } + private readonly string _tempCacheDir; + + public MsalClientFactoryTests() + { + _tempCacheDir = Path.Combine(Path.GetTempPath(), "MsalClientFactoryTests_" + Guid.NewGuid()); + Directory.CreateDirectory(_tempCacheDir); + } + + public void Dispose() + { + try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ } + } + + [Fact] + public async Task GetOrCreateAsync_SameClientId_ReturnsSameInstance() + { + var factory = new MsalClientFactory(_tempCacheDir); + + var pca1 = await factory.GetOrCreateAsync("clientA"); + var pca2 = await factory.GetOrCreateAsync("clientA"); + + Assert.Same(pca1, pca2); + } + + [Fact] + public async Task GetOrCreateAsync_DifferentClientIds_ReturnDifferentInstances() + { + var factory = new MsalClientFactory(_tempCacheDir); + + var pcaA = await factory.GetOrCreateAsync("clientA"); + var pcaB = await factory.GetOrCreateAsync("clientB"); + + Assert.NotSame(pcaA, pcaB); + } + + [Fact] + public async Task GetOrCreateAsync_ConcurrentCalls_DoNotCreateDuplicateInstances() + { + var factory = new MsalClientFactory(_tempCacheDir); + + // Run 10 concurrent calls with the same clientId + var tasks = Enumerable.Range(0, 10) + .Select(_ => factory.GetOrCreateAsync("clientConcurrent")) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + // All 10 results must be the exact same instance + var first = results[0]; + Assert.All(results, r => Assert.Same(first, r)); + } + + [Fact] + public void CacheDirectory_ResolvesToAppData_Not_Hardcoded() + { + // The default (no-arg) constructor must use %AppData%\SharepointToolbox\auth + var factory = new MsalClientFactory(); + var expectedBase = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var expectedDir = Path.Combine(expectedBase, "SharepointToolbox", "auth"); + + Assert.Equal(expectedDir, factory.CacheDirectory); + } } diff --git a/SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs b/SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs new file mode 100644 index 0000000..a27335b --- /dev/null +++ b/SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Identity.Client; +using Microsoft.Identity.Client.Extensions.Msal; + +namespace SharepointToolbox.Infrastructure.Auth; + +/// +/// Creates and caches one IPublicClientApplication per ClientId with a persistent MsalCacheHelper. +/// Never shares instances across different ClientIds (per-tenant isolation). +/// +public class MsalClientFactory +{ + private readonly Dictionary _clients = new(); + private readonly SemaphoreSlim _lock = new(1, 1); + + /// Cache directory for MSAL token files. + public string CacheDirectory { get; } + + /// Default constructor — uses %AppData%\SharepointToolbox\auth. + public MsalClientFactory() + : this(Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "SharepointToolbox", "auth")) + { } + + /// Constructor allowing a custom cache directory (used in tests). + public MsalClientFactory(string cacheDirectory) + { + CacheDirectory = cacheDirectory; + } + + /// + /// Returns the cached IPublicClientApplication for the given clientId, + /// or creates a new one (with MsalCacheHelper) on first call. + /// Thread-safe: concurrent callers with the same clientId receive the same instance. + /// + public async Task GetOrCreateAsync(string clientId) + { + await _lock.WaitAsync(); + try + { + if (_clients.TryGetValue(clientId, out var existing)) + return existing; + + Directory.CreateDirectory(CacheDirectory); + + var storageProps = new StorageCreationPropertiesBuilder( + $"msal_{clientId}.cache", CacheDirectory) + .Build(); + + var pca = PublicClientApplicationBuilder + .Create(clientId) + .WithDefaultRedirectUri() + .WithLegacyCacheCompatibility(false) + .Build(); + + var helper = await MsalCacheHelper.CreateAsync(storageProps); + helper.RegisterCache(pca.UserTokenCache); + + _clients[clientId] = pca; + return pca; + } + finally { _lock.Release(); } + } +}