feat(01-04): SessionManager singleton holding all ClientContext instances
- 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)
This commit is contained in:
@@ -15,6 +15,7 @@ namespace SharepointToolbox.Infrastructure.Auth;
|
||||
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>
|
||||
@@ -62,8 +63,22 @@ public class MsalClientFactory
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
103
SharepointToolbox/Services/SessionManager.cs
Normal file
103
SharepointToolbox/Services/SessionManager.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.SharePoint.Client;
|
||||
using PnP.Framework;
|
||||
using SharepointToolbox.Core.Models;
|
||||
using SharepointToolbox.Infrastructure.Auth;
|
||||
|
||||
namespace SharepointToolbox.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton that owns all live ClientContext instances.
|
||||
/// Every SharePoint operation goes through this class — callers MUST NOT store
|
||||
/// ClientContext references; request it fresh each time via GetOrCreateContextAsync.
|
||||
/// </summary>
|
||||
public class SessionManager
|
||||
{
|
||||
private readonly MsalClientFactory _msalFactory;
|
||||
private readonly Dictionary<string, ClientContext> _contexts = new();
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public SessionManager(MsalClientFactory msalFactory)
|
||||
{
|
||||
_msalFactory = msalFactory;
|
||||
}
|
||||
|
||||
/// <summary>Returns true if an authenticated ClientContext exists for this tenantUrl.</summary>
|
||||
public bool IsAuthenticated(string tenantUrl) =>
|
||||
_contexts.ContainsKey(NormalizeUrl(tenantUrl));
|
||||
|
||||
/// <summary>
|
||||
/// Returns an existing ClientContext or creates a new one via interactive MSAL login.
|
||||
/// Uses MsalClientFactory so the token cache is registered per-clientId before PnP acquires tokens.
|
||||
/// Only SessionManager holds ClientContext instances — never return to callers for storage.
|
||||
/// </summary>
|
||||
public async Task<ClientContext> GetOrCreateContextAsync(
|
||||
TenantProfile profile,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.TenantUrl);
|
||||
ArgumentException.ThrowIfNullOrEmpty(profile.ClientId);
|
||||
|
||||
var key = NormalizeUrl(profile.TenantUrl);
|
||||
|
||||
await _lock.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (_contexts.TryGetValue(key, out var existing))
|
||||
return existing;
|
||||
|
||||
// Ensure the per-clientId PCA + MsalCacheHelper is created BEFORE PnP acquires tokens.
|
||||
// MsalClientFactory.GetOrCreateAsync creates the PCA and registers MsalCacheHelper on it.
|
||||
// We then wire the same helper to PnP's internal token cache via tokenCacheCallback,
|
||||
// so both PCA and PnP share the same persistent cache file per clientId.
|
||||
await _msalFactory.GetOrCreateAsync(profile.ClientId);
|
||||
var cacheHelper = _msalFactory.GetCacheHelper(profile.ClientId);
|
||||
|
||||
var authManager = AuthenticationManager.CreateWithInteractiveLogin(
|
||||
clientId: profile.ClientId,
|
||||
openBrowserCallback: (url, port) =>
|
||||
{
|
||||
// The browser/WAM flow is handled by MSAL; this callback receives the
|
||||
// local redirect URL and port. No action needed here — MSAL opens the browser.
|
||||
},
|
||||
tokenCacheCallback: tokenCache =>
|
||||
{
|
||||
// Wire the same MsalCacheHelper to PnP's internal token cache so both
|
||||
// PnP and our PCA (from MsalClientFactory) share the same persistent
|
||||
// cache file at %AppData%\SharepointToolbox\auth\msal_{clientId}.cache
|
||||
cacheHelper.RegisterCache(tokenCache);
|
||||
});
|
||||
|
||||
var ctx = await authManager.GetContextAsync(profile.TenantUrl, ct);
|
||||
_contexts[key] = ctx;
|
||||
return ctx;
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the ClientContext and clears MSAL state for the given tenant.
|
||||
/// Idempotent — safe to call for tenants that were never authenticated.
|
||||
/// Called by "Clear Session" button and on tenant profile deletion.
|
||||
/// </summary>
|
||||
public async Task ClearSessionAsync(string tenantUrl)
|
||||
{
|
||||
var key = NormalizeUrl(tenantUrl);
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
if (_contexts.TryGetValue(key, out var ctx))
|
||||
{
|
||||
ctx.Dispose();
|
||||
_contexts.Remove(key);
|
||||
}
|
||||
}
|
||||
finally { _lock.Release(); }
|
||||
}
|
||||
|
||||
private static string NormalizeUrl(string url) =>
|
||||
url.TrimEnd('/').ToLowerInvariant();
|
||||
}
|
||||
Reference in New Issue
Block a user