- 01-04: wave 3 → 4 (01-03 is also wave 3; parallel executor would race) - 01-06: wave 4 → 5 (cascades from 01-04 fix); add FeatureTabBase UserControl for per-tab progress/cancel strip; bind StatusBar middle item to ProgressStatus instead of ConnectionStatus per locked CONTEXT.md decision - 01-07: wave 5 → 6 (cascades) - 01-08: wave 6 → 7 (cascades) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
267 lines
12 KiB
Markdown
267 lines
12 KiB
Markdown
---
|
|
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>
|