--- phase: 01-foundation plan: 04 subsystem: auth tags: [dotnet10, csharp, msal, msal-cache-helper, pnp-framework, sharepoint, csom, unit-tests, xunit, semaphoreslim, tdd] # Dependency graph requires: - 01-01 (solution scaffold, NuGet packages — Microsoft.Identity.Client, Microsoft.Identity.Client.Extensions.Msal, PnP.Framework) - 01-02 (TenantProfile model with ClientId/TenantUrl fields) - 01-03 (ProfileService/SettingsService — injection pattern) provides: - MsalClientFactory: per-ClientId IPublicClientApplication with MsalCacheHelper persistent cache - MsalClientFactory.GetCacheHelper(clientId): exposes MsalCacheHelper for PnP tokenCacheCallback wiring - SessionManager: singleton owning all live ClientContext instances with IsAuthenticated/GetOrCreateContextAsync/ClearSessionAsync affects: - 01-05 (TranslationSource/app setup — SessionManager ready for DI registration) - 01-06 (FeatureViewModelBase — SessionManager is the auth gateway for all feature commands) - 02-xx (all SharePoint feature services call SessionManager.GetOrCreateContextAsync) # Tech tracking tech-stack: added: [] patterns: - MsalClientFactory: per-clientId Dictionary + SemaphoreSlim(1,1) for concurrent-safe lazy creation - MsalCacheHelper stored per-clientId alongside PCA — exposed via GetCacheHelper() for PnP tokenCacheCallback wiring - SessionManager: per-tenantUrl Dictionary + SemaphoreSlim(1,1); NormalizeUrl (TrimEnd + ToLowerInvariant) for key consistency - PnP tokenCacheCallback pattern: cacheHelper.RegisterCache(tokenCache) wires persistent cache to PnP's internal MSAL token cache - ArgumentException.ThrowIfNullOrEmpty on all public method entry points requiring string arguments key-files: created: - SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs - SharepointToolbox/Services/SessionManager.cs - SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs - SharepointToolbox.Tests/Auth/SessionManagerTests.cs modified: [] key-decisions: - "MsalClientFactory stores both IPublicClientApplication and MsalCacheHelper per clientId — GetCacheHelper() exposes helper for PnP's tokenCacheCallback; PnP creates its own internal PCA so we cannot pass ours directly" - "SessionManager uses tokenCacheCallback to wire MsalCacheHelper to PnP's token cache — both PCA and PnP share the same persistent msal_{clientId}.cache file, preventing token duplication" - "CacheDirectory is a constructor parameter with a no-arg default — enables test isolation without real %AppData% writes" - "Interactive login test marked Skip in unit test suite — GetOrCreateContextAsync integration requires browser/WAM flow that cannot run in CI" patterns-established: - "Auth token cache wiring: Always call MsalClientFactory.GetOrCreateAsync first, then use GetCacheHelper() in PnP's tokenCacheCallback — ensures per-clientId cache isolation" - "SessionManager is the single source of truth for ClientContext: callers must not store returned contexts" requirements-completed: - FOUND-03 - FOUND-04 # Metrics duration: 4min completed: 2026-04-02 --- # Phase 1 Plan 04: Authentication Layer Summary **Per-tenant MSAL PCA with MsalCacheHelper persistent cache (one file per clientId in %AppData%) and SessionManager singleton owning all live PnP ClientContext instances — per-tenant isolation verified by 12 unit tests** ## Performance - **Duration:** 4 min - **Started:** 2026-04-02T10:20:49Z - **Completed:** 2026-04-02T10:25:05Z - **Tasks:** 2 - **Files modified:** 4 ## Accomplishments - MsalClientFactory creates one IPublicClientApplication per unique clientId (never shared across tenants); SemaphoreSlim prevents duplicate creation under concurrent calls - MsalCacheHelper registered on each PCA's UserTokenCache; persistent cache files at `%AppData%\SharepointToolbox\auth\msal_{clientId}.cache` - SessionManager is the sole holder of ClientContext instances; IsAuthenticated/ClearSessionAsync/GetOrCreateContextAsync with full argument validation - ClearSessionAsync calls ctx.Dispose() and removes from internal dictionary; idempotent for unknown tenants - 12 unit tests pass (4 MsalClientFactory + 8 SessionManager), 1 integration test correctly skipped - PnP tokenCacheCallback pattern established: `cacheHelper.RegisterCache(tokenCache)` wires the factory-managed helper to PnP's internal MSAL token cache ## Task Commits Each task was committed atomically: 1. **Task 1: MsalClientFactory — per-ClientId PCA with MsalCacheHelper** - `0295519` (feat) 2. **Task 2: SessionManager — singleton ClientContext holder** - `158aab9` (feat) **Plan metadata:** (docs commit follows) ## Files Created/Modified - `SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs` - Per-clientId PCA + MsalCacheHelper; CacheDirectory constructor param; GetCacheHelper() for PnP wiring - `SharepointToolbox/Services/SessionManager.cs` - Singleton; IsAuthenticated/GetOrCreateContextAsync/ClearSessionAsync; NormalizeUrl; tokenCacheCallback wiring - `SharepointToolbox.Tests/Auth/MsalClientFactoryTests.cs` - 4 unit tests: same-instance, different-instances, concurrent-safe, AppData path; IDisposable temp dir cleanup - `SharepointToolbox.Tests/Auth/SessionManagerTests.cs` - 8 unit tests + 1 skipped: IsAuthenticated before/after, ClearSessionAsync idempotency, ArgumentException on null/empty TenantUrl and ClientId ## Decisions Made - `MsalClientFactory` stores `MsalCacheHelper` per clientId alongside the `IPublicClientApplication`. Added `GetCacheHelper(clientId)` to expose it. This is required because PnP.Framework's `CreateWithInteractiveLogin` creates its own internal PCA — we cannot pass our PCA to PnP directly. The `tokenCacheCallback` (`Action`) is the bridge: we call `cacheHelper.RegisterCache(tokenCache)` so PnP's internal cache uses the same persistent file. - `CacheDirectory` is a public constructor parameter with a no-arg default pointing to `%AppData%\SharepointToolbox\auth`. Tests inject a temp directory to avoid real AppData writes and ensure cleanup. - Interactive login test (`GetOrCreateContextAsync_CreatesContext`) is marked `[Fact(Skip = "Requires interactive MSAL — integration test only")]`. Browser/WAM flow cannot run in automated unit tests. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 2 - Missing Critical] Added GetCacheHelper() to MsalClientFactory** - **Found during:** Task 2 (SessionManager implementation) - **Issue:** Plan's skeleton used a non-existent PnP overload that accepts `IPublicClientApplication` directly. PnP.Framework 1.18.0's `CreateWithInteractiveLogin` does not accept a PCA parameter — only `tokenCacheCallback: Action`. Without `GetCacheHelper()`, there was no way to wire the same MsalCacheHelper to PnP's internal token cache. - **Fix:** Added `_helpers` dictionary to `MsalClientFactory`, stored `MsalCacheHelper` alongside PCA, exposed via `GetCacheHelper(clientId)`. `SessionManager` calls `GetOrCreateAsync` first, then `GetCacheHelper`, then uses it in `tokenCacheCallback`. - **Files modified:** `SharepointToolbox/Infrastructure/Auth/MsalClientFactory.cs`, `SharepointToolbox/Services/SessionManager.cs` - **Verification:** 12/12 unit tests pass, 0 build warnings - **Committed in:** 158aab9 (Task 2 commit) --- **Total deviations:** 1 auto-fixed (Rule 2 — PnP API surface mismatch required bridge method) **Impact on plan:** The key invariant is preserved: MsalClientFactory is called first, the per-clientId MsalCacheHelper is wired to PnP before any token acquisition. One method added to factory, no scope creep. ## Issues Encountered None beyond the auto-fixed deviation above. ## User Setup Required None — MSAL cache files are created on demand in %AppData%. No external service configuration required. ## Next Phase Readiness - `SessionManager` ready for DI registration in plan 01-05 or 01-06 (singleton lifetime) - `MsalClientFactory` ready for DI (singleton lifetime) - Auth layer complete: every SharePoint operation in Phases 2-4 can call `SessionManager.GetOrCreateContextAsync(profile)` to get a live `ClientContext` - Per-tenant isolation (one PCA + cache file per ClientId) confirmed by unit tests — token bleed between MSP client tenants is prevented --- *Phase: 01-foundation* *Completed: 2026-04-02*