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(); }
+ }
+}