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
This commit is contained in:
@@ -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;
|
namespace SharepointToolbox.Tests.Auth;
|
||||||
|
|
||||||
public class MsalClientFactoryTests
|
[Trait("Category", "Unit")]
|
||||||
|
public class MsalClientFactoryTests : IDisposable
|
||||||
{
|
{
|
||||||
[Fact(Skip = "Wave 0 stub — implemented in plan 01-04")]
|
private readonly string _tempCacheDir;
|
||||||
public void CreateClient_Returns_ConfiguredPublicClientApplication() { }
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs
Normal file
69
SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates and caches one IPublicClientApplication per ClientId with a persistent MsalCacheHelper.
|
||||||
|
/// Never shares instances across different ClientIds (per-tenant isolation).
|
||||||
|
/// </summary>
|
||||||
|
public class MsalClientFactory
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, IPublicClientApplication> _clients = new();
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>Cache directory for MSAL token files.</summary>
|
||||||
|
public string CacheDirectory { get; }
|
||||||
|
|
||||||
|
/// <summary>Default constructor — uses %AppData%\SharepointToolbox\auth.</summary>
|
||||||
|
public MsalClientFactory()
|
||||||
|
: this(Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"SharepointToolbox", "auth"))
|
||||||
|
{ }
|
||||||
|
|
||||||
|
/// <summary>Constructor allowing a custom cache directory (used in tests).</summary>
|
||||||
|
public MsalClientFactory(string cacheDirectory)
|
||||||
|
{
|
||||||
|
CacheDirectory = cacheDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IPublicClientApplication> 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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user