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:
Dev
2026-04-02 12:25:01 +02:00
parent 02955199f6
commit 158aab96b2
3 changed files with 217 additions and 3 deletions

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