--- phase: 01-foundation plan: 04 type: execute wave: 4 depends_on: - 01-02 - 01-03 files_modified: - SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs - SharepointToolbox/Services/SessionManager.cs - SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs - SharepointToolbox.Tests/Auth/SessionManagerTests.cs autonomous: true requirements: - FOUND-03 - FOUND-04 must_haves: truths: - "MsalClientFactory creates one IPublicClientApplication per ClientId — never shares instances across tenants" - "MsalCacheHelper persists token cache to %AppData%\\SharepointToolbox\\auth\\msal_{clientId}.cache" - "SessionManager.GetOrCreateContextAsync returns a cached ClientContext on second call without interactive login" - "SessionManager.ClearSessionAsync removes MSAL accounts and disposes ClientContext for the specified tenant" - "SessionManager is the only class in the codebase holding ClientContext instances" artifacts: - path: "SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs" provides: "Per-ClientId IPublicClientApplication with MsalCacheHelper" contains: "MsalCacheHelper" - path: "SharepointToolbox/Services/SessionManager.cs" provides: "Singleton holding all ClientContext instances and auth state" exports: ["GetOrCreateContextAsync", "ClearSessionAsync", "IsAuthenticated"] key_links: - from: "SharepointToolbox/Services/SessionManager.cs" to: "SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs" via: "Injected dependency — SessionManager calls MsalClientFactory.GetOrCreateAsync(clientId)" pattern: "GetOrCreateAsync" - from: "SharepointToolbox/Services/SessionManager.cs" to: "PnP.Framework AuthenticationManager" via: "CreateWithInteractiveLogin using MSAL PCA" pattern: "AuthenticationManager" --- Build the authentication layer: MsalClientFactory (per-tenant MSAL client with persistent cache) and SessionManager (singleton holding all live ClientContext instances). This is the security-critical component — one IPublicClientApplication per ClientId, never shared. Purpose: Every SharePoint operation in Phases 2-4 goes through SessionManager. Getting the per-tenant isolation and token cache correct now prevents auth token bleed between client tenants — a critical security property for MSP use. Output: MsalClientFactory + SessionManager + unit tests validating per-tenant isolation. @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/01-foundation/01-CONTEXT.md @.planning/phases/01-foundation/01-RESEARCH.md @.planning/phases/01-foundation/01-02-SUMMARY.md @.planning/phases/01-foundation/01-03-SUMMARY.md ```csharp public class TenantProfile { public string Name { get; set; } public string TenantUrl { get; set; } public string ClientId { get; set; } } ``` Task 1: MsalClientFactory — per-ClientId PCA with MsalCacheHelper SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs, SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs - Test: GetOrCreateAsync("clientA") and GetOrCreateAsync("clientA") return the same instance (no duplicate creation) - Test: GetOrCreateAsync("clientA") and GetOrCreateAsync("clientB") return different instances (per-tenant isolation) - Test: Concurrent calls to GetOrCreateAsync with same clientId do not create duplicate instances (SemaphoreSlim) - Test: Cache directory path resolves to %AppData%\SharepointToolbox\auth\ (not a hardcoded path) Create `Infrastructure/Auth/` directory. **MsalClientFactory.cs** — implement exactly as per research Pattern 3: ```csharp namespace SharepointToolbox.Infrastructure.Auth; public class MsalClientFactory { private readonly Dictionary _clients = new(); private readonly SemaphoreSlim _lock = new(1, 1); private readonly string _cacheDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "SharepointToolbox", "auth"); public async Task GetOrCreateAsync(string clientId) { await _lock.WaitAsync(); try { if (_clients.TryGetValue(clientId, out var existing)) return existing; var storageProps = new StorageCreationPropertiesBuilder( $"msal_{clientId}.cache", _cacheDir) .Build(); var pca = PublicClientApplicationBuilder .Create(clientId) .WithDefaultRedirectUri() .WithLegacyCacheCompatibility(false) .Build(); var helper = await MsalCacheHelper.CreateAsync(storageProps); helper.RegisterCache(pca.UserTokenCache); _clients[clientId] = pca; return pca; } finally { _lock.Release(); } } } ``` **MsalClientFactoryTests.cs** — Replace stub. Tests for per-ClientId isolation and idempotency. Since MsalCacheHelper creates real files, tests must use a temp directory and clean up. Use `[Trait("Category", "Unit")]` on all tests. Mock or subclass `MsalClientFactory` for the concurrent test to avoid real MSAL overhead. cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~MsalClientFactoryTests" 2>&1 | tail -10 MsalClientFactoryTests pass. Different clientIds produce different instances. Same clientId produces same instance on second call. Task 2: SessionManager — singleton ClientContext holder SharepointToolbox/Services/SessionManager.cs, SharepointToolbox.Tests/Auth/SessionManagerTests.cs - Test: IsAuthenticated(tenantUrl) returns false before any authentication - Test: After GetOrCreateContextAsync succeeds, IsAuthenticated(tenantUrl) returns true - Test: ClearSessionAsync removes authentication state for the specified tenant - Test: ClearSessionAsync on unknown tenantUrl does not throw (idempotent) - Test: ClientContext is disposed on ClearSessionAsync (verify via mock/wrapper) - Test: GetOrCreateContextAsync throws ArgumentException for null/empty tenantUrl or clientId **SessionManager.cs** — singleton, owns all ClientContext instances: ```csharp namespace SharepointToolbox.Services; 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; } public bool IsAuthenticated(string tenantUrl) => _contexts.ContainsKey(NormalizeUrl(tenantUrl)); /// /// Returns existing ClientContext or creates a new one via interactive MSAL login. /// 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; var pca = await _msalFactory.GetOrCreateAsync(profile.ClientId); var authManager = AuthenticationManager.CreateWithInteractiveLogin( profile.ClientId, (url, port) => { // WAM/browser-based interactive login return pca.AcquireTokenInteractive( new[] { "https://graph.microsoft.com/.default" }) .ExecuteAsync(ct); }); var ctx = await authManager.GetContextAsync(profile.TenantUrl); _contexts[key] = ctx; return ctx; } finally { _lock.Release(); } } /// /// Clears MSAL accounts and disposes the ClientContext for the given tenant. /// 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(); } ``` Note on PnP AuthenticationManager: The exact API for `CreateWithInteractiveLogin` with MSAL PCA may vary in PnP.Framework 1.18.0. The implementation above is a skeleton — executor should verify the PnP API surface and adjust accordingly. The key invariant is: `MsalClientFactory.GetOrCreateAsync` is called first, then PnP creates the context using the returned PCA. Do NOT call `PublicClientApplicationBuilder.Create` directly in SessionManager. **SessionManagerTests.cs** — Replace stub. Use Moq to mock `MsalClientFactory`. Test `IsAuthenticated`, `ClearSessionAsync` idempotency, and argument validation. Interactive login cannot be tested in unit tests — mark `GetOrCreateContextAsync_CreatesContext` as `[Fact(Skip = "Requires interactive MSAL — integration test only")]`. All other tests in `[Trait("Category", "Unit")]`. cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SessionManagerTests" 2>&1 | tail -10 SessionManagerTests pass (interactive login test skipped). IsAuthenticated, ClearSessionAsync, and argument validation tests are green. SessionManager is the only holder of ClientContext. - `dotnet test --filter "Category=Unit"` passes - MsalClientFactory._clients dictionary holds one entry per unique clientId - SessionManager.ClearSessionAsync calls ctx.Dispose() (verified via test) - No class outside SessionManager stores a ClientContext reference Auth layer unit tests green. Per-tenant isolation (one PCA per ClientId, one context per tenantUrl) confirmed by tests. SessionManager is the single source of truth for authenticated connections. After completion, create `.planning/phases/01-foundation/01-04-SUMMARY.md`