Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/01-foundation/01-04-PLAN.md
Dev 724fdc550d chore: complete v1.0 milestone
Archive 5 phases (36 plans) to milestones/v1.0-phases/.
Archive roadmap, requirements, and audit to milestones/.
Evolve PROJECT.md with shipped state and validated requirements.
Collapse ROADMAP.md to one-line milestone summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:19:03 +02:00

12 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-foundation 04 execute 4
01-02
01-03
SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs
SharepointToolbox/Services/SessionManager.cs
SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs
SharepointToolbox.Tests/Auth/SessionManagerTests.cs
true
FOUND-03
FOUND-04
truths artifacts key_links
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
path provides contains
SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs Per-ClientId IPublicClientApplication with MsalCacheHelper MsalCacheHelper
path provides exports
SharepointToolbox/Services/SessionManager.cs Singleton holding all ClientContext instances and auth state
GetOrCreateContextAsync
ClearSessionAsync
IsAuthenticated
from to via pattern
SharepointToolbox/Services/SessionManager.cs SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs Injected dependency — SessionManager calls MsalClientFactory.GetOrCreateAsync(clientId) GetOrCreateAsync
from to via pattern
SharepointToolbox/Services/SessionManager.cs PnP.Framework AuthenticationManager CreateWithInteractiveLogin using MSAL PCA 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.

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

@.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<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.
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<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")]`.
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

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

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