- Write StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl test (RED) - Create ISessionManager interface for testability - Implement ISessionManager on SessionManager - Add PermissionsViewModel stub (NotImplementedException) to satisfy compile
104 lines
4.1 KiB
C#
104 lines
4.1 KiB
C#
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 : ISessionManager
|
|
{
|
|
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();
|
|
}
|