- SessionManager owns all ClientContexts; callers must not store references - IsAuthenticated(tenantUrl) returns false before auth, true after GetOrCreateContextAsync - ClearSessionAsync disposes ClientContext and removes state (idempotent for unknown tenants) - GetOrCreateContextAsync validates null/empty TenantUrl and ClientId (ArgumentException) - MsalClientFactory.GetCacheHelper() added — exposes helper for PnP tokenCacheCallback wiring - 8 unit tests pass, 1 interactive-login test skipped (integration-only)
85 lines
3.0 KiB
C#
85 lines
3.0 KiB
C#
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 Dictionary<string, MsalCacheHelper> _helpers = 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;
|
|
_helpers[clientId] = helper;
|
|
return pca;
|
|
}
|
|
finally { _lock.Release(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the MsalCacheHelper for the given clientId.
|
|
/// GetOrCreateAsync must be called first — throws InvalidOperationException otherwise.
|
|
/// Used by SessionManager to wire PnP's internal token cache to the same persistent cache file.
|
|
/// </summary>
|
|
public MsalCacheHelper GetCacheHelper(string clientId)
|
|
{
|
|
if (!_helpers.TryGetValue(clientId, out var helper))
|
|
throw new InvalidOperationException(
|
|
$"No cache helper found for clientId '{clientId}'. Call GetOrCreateAsync first.");
|
|
return helper;
|
|
}
|
|
}
|