All checks were successful
Release zip package / release (push) Successful in 10s
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>
12 KiB
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 |
|
|
true |
|
|
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`