---
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"
---

<objective>
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.
</objective>

<execution_context>
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.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

<interfaces>
<!-- From Core/Models/TenantProfile.cs (plan 01-02) -->
```csharp
public class TenantProfile
{
    public string Name { get; set; }
    public string TenantUrl { get; set; }
    public string ClientId { get; set; }
}
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: MsalClientFactory — per-ClientId PCA with MsalCacheHelper</name>
  <files>
    SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs,
    SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs
  </files>
  <behavior>
    - 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)
  </behavior>
  <action>
    Create `Infrastructure/Auth/` directory.

    **MsalClientFactory.cs** — implement exactly as per research Pattern 3:
    ```csharp
    namespace SharepointToolbox.Infrastructure.Auth;

    public class MsalClientFactory
    {
        private readonly Dictionary<string, IPublicClientApplication> _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<IPublicClientApplication> 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.
  </action>
  <verify>
    <automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~MsalClientFactoryTests" 2>&1 | tail -10</automated>
  </verify>
  <done>MsalClientFactoryTests pass. Different clientIds produce different instances. Same clientId produces same instance on second call.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: SessionManager — singleton ClientContext holder</name>
  <files>
    SharepointToolbox/Services/SessionManager.cs,
    SharepointToolbox.Tests/Auth/SessionManagerTests.cs
  </files>
  <behavior>
    - 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
  </behavior>
  <action>
    **SessionManager.cs** — singleton, owns all ClientContext instances:
    ```csharp
    namespace SharepointToolbox.Services;

    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;
        }

        public bool IsAuthenticated(string tenantUrl) =>
            _contexts.ContainsKey(NormalizeUrl(tenantUrl));

        /// <summary>
        /// Returns existing ClientContext or creates a new one via interactive MSAL login.
        /// 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;

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

        /// <summary>
        /// Clears MSAL accounts and disposes the ClientContext for the given tenant.
        /// 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();
    }
    ```

    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")]`.
  </action>
  <verify>
    <automated>cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SessionManagerTests" 2>&1 | tail -10</automated>
  </verify>
  <done>SessionManagerTests pass (interactive login test skipped). IsAuthenticated, ClearSessionAsync, and argument validation tests are green. SessionManager is the only holder of ClientContext.</done>
</task>

</tasks>

<verification>
- `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
</verification>

<success_criteria>
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.
</success_criteria>

<output>
After completion, create `.planning/phases/01-foundation/01-04-SUMMARY.md`
</output>
