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:
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