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