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; /// /// 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. /// public class SessionManager { private readonly MsalClientFactory _msalFactory; private readonly Dictionary _contexts = new(); private readonly SemaphoreSlim _lock = new(1, 1); public SessionManager(MsalClientFactory msalFactory) { _msalFactory = msalFactory; } /// Returns true if an authenticated ClientContext exists for this tenantUrl. public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl)); /// /// 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. /// public async Task 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(); } } /// /// 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. /// 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(); }