diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 7e06bd1..ae81b1f 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -11,8 +11,8 @@ Requirements for initial release. Each maps to roadmap phases. - [x] **FOUND-01**: Application built with C#/WPF (.NET 10 LTS) using MVVM architecture - [x] **FOUND-02**: Multi-tenant profile registry — user can create, rename, delete, and switch between tenant profiles (tenant URL, client ID, display name) -- [ ] **FOUND-03**: Multi-tenant session caching — user stays authenticated across tenant switches without re-logging in (MSAL token cache per tenant) -- [ ] **FOUND-04**: Interactive Azure AD OAuth login via browser — no client secrets or certificates stored +- [x] **FOUND-03**: Multi-tenant session caching — user stays authenticated across tenant switches without re-logging in (MSAL token cache per tenant) +- [x] **FOUND-04**: Interactive Azure AD OAuth login via browser — no client secrets or certificates stored - [x] **FOUND-05**: All long-running operations report progress to the UI in real-time - [x] **FOUND-06**: User can cancel any long-running operation mid-execution - [x] **FOUND-07**: All errors surface to the user with actionable messages — no silent failures @@ -117,8 +117,8 @@ Which phases cover which requirements. Updated during roadmap creation. |-------------|-------|--------| | FOUND-01 | Phase 1 | Complete | | FOUND-02 | Phase 1 | Complete | -| FOUND-03 | Phase 1 | Pending | -| FOUND-04 | Phase 1 | Pending | +| FOUND-03 | Phase 1 | Complete | +| FOUND-04 | Phase 1 | Complete | | FOUND-05 | Phase 1 | Complete | | FOUND-06 | Phase 1 | Complete | | FOUND-07 | Phase 1 | Complete | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index cc3fce7..1c24e2c 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -104,7 +104,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 | Phase | Plans Complete | Status | Completed | |-------|----------------|--------|-----------| -| 1. Foundation | 4/8 | In Progress| | +| 1. Foundation | 5/8 | In Progress| | | 2. Permissions | 0/? | Not started | - | | 3. Storage and File Operations | 0/? | Not started | - | | 4. Bulk Operations and Provisioning | 0/? | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 51746a5..35f0f72 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,14 +3,14 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone status: planning -stopped_at: Completed 01-foundation-05-PLAN.md -last_updated: "2026-04-02T10:19:35.873Z" +stopped_at: Completed 01-foundation-04-PLAN.md +last_updated: "2026-04-02T10:26:38.489Z" last_activity: 2026-04-02 — Roadmap created, requirements mapped, all 42 v1 requirements assigned to phases progress: total_phases: 5 completed_phases: 0 total_plans: 8 - completed_plans: 4 + completed_plans: 5 percent: 13 --- @@ -54,6 +54,7 @@ Progress: [█░░░░░░░░░] 13% | Phase 01-foundation P02 | 1 | 2 tasks | 7 files | | Phase 01-foundation P03 | 8 | 2 tasks | 7 files | | Phase 01-foundation P05 | 4min | 2 tasks | 8 files | +| Phase 01-foundation P04 | 4 | 2 tasks | 4 files | ## Accumulated Context @@ -76,6 +77,10 @@ Recent decisions affecting current work: - [Phase 01-foundation]: LoadAsync on corrupt JSON throws InvalidDataException (not silent empty) — explicit failure protects against silent data loss - [Phase 01-foundation]: Strings.Designer.cs maintained manually — ResXFileCodeGenerator is VS-only, not run by dotnet build; only ResourceManager accessor needed - [Phase 01-foundation]: EmbeddedResource uses Update not Include in SDK-style project — SDK auto-includes all .resx; Include causes NETSDK1022 duplicate error +- [Phase 01-foundation]: MsalClientFactory stores MsalCacheHelper per clientId and exposes GetCacheHelper() — PnP creates its own internal PCA so tokenCacheCallback is the bridge for shared persistent cache +- [Phase 01-foundation]: SessionManager is the single holder of ClientContext instances — callers must not store returned contexts +- [Phase 01-foundation]: CacheDirectory is a constructor parameter (no-arg defaults to AppData) — enables test isolation without real filesystem writes +- [Phase 01-foundation]: Interactive login test marked Skip in unit suite — browser/WAM MSAL flow cannot run in automated CI ### Pending Todos @@ -89,6 +94,6 @@ None yet. ## Session Continuity -Last session: 2026-04-02T10:19:35.870Z -Stopped at: Completed 01-foundation-05-PLAN.md +Last session: 2026-04-02T10:26:38.487Z +Stopped at: Completed 01-foundation-04-PLAN.md Resume file: None diff --git a/.planning/phases/01-foundation/01-04-SUMMARY.md b/.planning/phases/01-foundation/01-04-SUMMARY.md new file mode 100644 index 0000000..dca8f13 --- /dev/null +++ b/.planning/phases/01-foundation/01-04-SUMMARY.md @@ -0,0 +1,135 @@ +--- +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*