# ZB.MOM.WW.Auth Shared Library Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Build the `ZB.MOM.WW.Auth` shared library set (4 NuGet packages) that normalizes LDAP identity, the canonical role seam, API-key auth, and ASP.NET cookie/claim conventions so OtOpcUa, MxAccessGateway, and ScadaBridge can stop re-implementing auth. **Architecture:** A new standalone repo (`~/Desktop/scadaproj/ZB.MOM.WW.Auth`), .NET 10, four library projects with one DLL each — `Abstractions` (pure contracts), `Ldap` (bind-then-search authn), `ApiKeys` (peppered-HMAC keys + SQLite store), `AspNetCore` (cookie/claim/DI helpers). The reference implementations are lifted and generalized: `Ldap` from ScadaBridge's hardened `LdapAuthService`, `ApiKeys` from mxaccessgw's `Security/Authentication` pipeline. Libraries are linked into each consumer and copied to its `bin/` — **no central auth service**. Consumer adoption is a **separate follow-on plan**; this plan delivers the library + tests + packages only. **Tech Stack:** .NET 10, C#; xUnit + `Xunit.SkippableFact`; `Novell.Directory.Ldap.NETStandard`; `Microsoft.Data.Sqlite`; `Microsoft.Extensions.{DependencyInjection,Options}`, `Microsoft.AspNetCore.Authentication.*`; central package management (`Directory.Packages.props`); `.slnx` solution. **Source references (read-only, to port from):** - LDAP: `~/Desktop/ScadaBridge/src/ZB.MOM.WW.ScadaBridge.Security/LdapAuthService.cs`, `SecurityOptions.cs`, `SecurityOptionsValidator.cs` - API keys: `~/Desktop/MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/*` (`ApiKeyParser`, `ApiKeySecretHasher`, `ApiKeySecretGenerator`, `ApiKeyVerifier`, `Sqlite*Store`, `SqliteAuthSchema`, `ApiKeyAdmin*`) - Design: `~/Desktop/scadaproj/components/auth/spec/SPEC.md`, `spec/CANONICAL-ROLES.md`, `shared-contract/ZB.MOM.WW.Auth.md` **Conventions for every task:** TDD (@superpowers-extended-cc:test-driven-development) — failing test first, minimal impl, green, commit. File-scoped namespaces, `sealed` by default, `Async` suffix on Task-returning methods. Never log secrets. Commit after each green task. --- ## Phase 0 — Scaffold ### Task 1: Create repo, solution, and project shells **Classification:** small **Estimated implement time:** ~5 min **Parallelizable with:** none (everything depends on this) **Files:** - Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx` - Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/Directory.Build.props` - Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/Directory.Packages.props` - Create: `~/Desktop/scadaproj/ZB.MOM.WW.Auth/.gitignore` - Create: `src/ZB.MOM.WW.Auth.Abstractions/ZB.MOM.WW.Auth.Abstractions.csproj` - Create: `src/ZB.MOM.WW.Auth.Ldap/ZB.MOM.WW.Auth.Ldap.csproj` - Create: `src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj` - Create: `src/ZB.MOM.WW.Auth.AspNetCore/ZB.MOM.WW.Auth.AspNetCore.csproj` - Create: `tests/ZB.MOM.WW.Auth.Ldap.Tests/…csproj`, `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/…csproj`, `tests/ZB.MOM.WW.Auth.AspNetCore.Tests/…csproj` **Steps:** 1. `cd ~/Desktop && mkdir ZB.MOM.WW.Auth && cd ZB.MOM.WW.Auth && git init && dotnet new gitignore` 2. `dotnet new sln -n ZB.MOM.WW.Auth --format slnx` (if `.slnx` unsupported by the SDK, use default `.sln`). 3. Scaffold projects: `dotnet new classlib -f net10.0 -o src/` for the 4 libs; `dotnet new xunit -f net10.0 -o tests/.Tests` for the 3 test projects. Delete the default `Class1.cs`/`UnitTest1.cs`. 4. Project refs: `.Ldap`/`.ApiKeys`/`.AspNetCore` → `.Abstractions`; `.AspNetCore` → `.Ldap` + `.ApiKeys`; each test project → its lib (+ `.Abstractions`). 5. `Directory.Build.props`: `net10.0`, `enable`, `enable`, `latest`, `0.1.0` (lockstep), `true`. 6. `Directory.Packages.props`: pin `Novell.Directory.Ldap.NETStandard`, `Microsoft.Data.Sqlite`, `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions`, `Microsoft.AspNetCore.Authentication.Cookies`/`.JwtBearer`, `Microsoft.AspNetCore.Authorization`, and test packages `xunit`, `xunit.runner.visualstudio`, `Xunit.SkippableFact`, `Microsoft.NET.Test.Sdk`. 7. `dotnet sln add` all projects; `dotnet build` to confirm the empty solution compiles. 8. **Commit:** `git add -A && git commit -m "chore: scaffold ZB.MOM.WW.Auth solution and projects"` **Acceptance:** `dotnet build` green; 7 projects in the solution. --- ## Phase 1 — Abstractions (pure contracts) ### Task 2: Abstractions — all contract types **Classification:** small **Estimated implement time:** ~5 min **Parallelizable with:** none (gates all impl tasks) These are declarations only (no behavior) → no unit tests; correctness is enforced by compilation and by the impl-task tests that consume them. **Files:** - Create: `src/ZB.MOM.WW.Auth.Abstractions/Ldap/LdapOptions.cs` (+ `LdapTransport`, `LdapAuthResult`, `LdapAuthFailure`, `ILdapAuthService`) - Create: `src/ZB.MOM.WW.Auth.Abstractions/Roles/CanonicalRole.cs` (+ `IGroupRoleMapper`, `GroupRoleMapping`) - Create: `src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs` (`ApiKeyOptions`, `IApiKeyVerifier`, `ApiKeyVerification`, `ApiKeyIdentity`, `ApiKeyFailure`, `ApiKeyRecord`, `IApiKeyStore`, `IApiKeyAdminStore`, `ApiKeyAuditEntry`) **Step 1: Write the types** — transcribe the contract from `components/auth/shared-contract/ZB.MOM.WW.Auth.md` verbatim (it is already valid C#). Canonical enum exactly: ```csharp namespace ZB.MOM.WW.Auth.Abstractions.Roles; public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator } ``` **Step 2: Build** — `dotnet build src/ZB.MOM.WW.Auth.Abstractions`. Expected: success. **Step 3: Commit** — `git commit -am "feat(abstractions): auth contracts, canonical roles, api-key types"` **Acceptance:** Abstractions compiles; no dependency beyond the BCL. --- ## Phase 2 — Ldap (authn) — parallel with Phase 3 ### Task 3: LDAP filter & DN escaping **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** Task 8, Task 9, Task 10 **Files:** - Create: `src/ZB.MOM.WW.Auth.Ldap/Internal/LdapEscaping.cs` - Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapEscapingTests.cs` **Step 1: Failing tests** (port the escaping rules from ScadaBridge `LdapAuthService`): ```csharp [Theory] [InlineData("a*b", @"a\2ab")] [InlineData("a(b)", @"a\28b\29")] [InlineData(@"a\b", @"a\5cb")] public void EscapesLdapFilterMetacharacters(string raw, string expected) => Assert.Equal(expected, LdapEscaping.Filter(raw)); [Fact] public void EscapesDnPerRfc4514() => Assert.Equal(@"\#cn\,test", LdapEscaping.Dn("#cn,test")); ``` **Step 2:** `dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests --filter LdapEscapingTests` → FAIL. **Step 3:** Implement `LdapEscaping.Filter` (escape `* ( ) \ NUL` → `\2a \28 \29 \5c \00`) and `LdapEscaping.Dn` (RFC 4514 leading `# space`, and `, + " \ < > ;`). **Step 4:** Re-run → PASS. **Step 5:** `git commit -am "feat(ldap): RFC-4514 / filter escaping"` ### Task 4: ILdapConnection seam + Novell adapter + fake **Classification:** small **Estimated implement time:** ~5 min **Parallelizable with:** Task 8, Task 9, Task 10 **Files:** - Create: `src/ZB.MOM.WW.Auth.Ldap/Internal/ILdapConnection.cs` (+ `ILdapConnectionFactory`) - Create: `src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs` (wraps `Novell.Directory.Ldap.LdapConnection`) - Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/FakeLdapConnection.cs` (test double) The seam exists so `LdapAuthService` logic is unit-testable without a live server. Methods: `Connect(host,port,transport,ct)`, `Bind(dn,password,ct)`, `Task> Search(base,filter,attrs,ct)`, `Dispose`. **Steps:** Write `FakeLdapConnection` first (records binds, returns scripted search results, can throw on a given bind to simulate bad creds) → it's the test harness for Task 5/6. Build. Commit `feat(ldap): connection seam + Novell adapter + test fake`. ### Task 5: LdapAuthService — happy path, transport enforcement, options validation **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 8–14 (different project) **Blocked by:** Task 3, Task 4 **Files:** - Create: `src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs` - Create: `src/ZB.MOM.WW.Auth.Ldap/LdapOptionsValidator.cs` - Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceTests.cs`, `LdapOptionsValidatorTests.cs` **Step 1: Failing tests** (inject `FakeLdapConnection`): ```csharp [Fact] public async Task SucceedsAndReturnsGroups_OnValidCredentials() { var fake = new FakeLdapConnection() .WithUserEntry("cn=alice,dc=x", memberOf: ["cn=Engineers,...", "cn=Viewers,..."]); var svc = new LdapAuthService(Opts(), _ => fake, NullLogger); var r = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None); Assert.True(r.Succeeded); Assert.Equal(["Engineers", "Viewers"], r.Groups); // CN= stripped } [Fact] public void Validator_Rejects_PlainLdap_WhenNotAllowInsecure() { var v = new LdapOptionsValidator().Validate(null, Opts(transport: LdapTransport.None, allowInsecure: false)); Assert.True(v.Failed); } ``` **Step 2:** run → FAIL. **Step 3:** Implement the bind-then-search flow per SPEC §2: connect (enforce `Transport` unless `AllowInsecure`); service-account bind; search `({UserNameAttribute}={LdapEscaping.Filter(username.Trim())})`; reject 0/≥2 matches; re-bind as user DN with password; read `GroupAttribute`; strip `CN=`. Validator enforces transport + required `Server`/`SearchBase`. Port logic from ScadaBridge `LdapAuthService.cs`. **Step 4:** run → PASS. **Step 5:** `git commit -am "feat(ldap): bind-then-search happy path + options validation"` ### Task 6: LdapAuthService — failure modes (fail-closed) **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** Task 8–14 **Blocked by:** Task 5 **Files:** - Modify: `src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs` - Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailureTests.cs` **Step 1: Failing tests** covering each `LdapAuthFailure`: `BadCredentials` (user bind throws), `UserNotFound` (0 search hits), `AmbiguousUser` (≥2 hits), `GroupLookupFailed` → **must return Failed, never admit with zero groups**, `ServiceAccountBindFailed` (distinct from bad user creds), `Disabled` (Enabled=false). Assert each maps to the right enum and never throws to the caller. **Step 2:** run → FAIL. **Step 3:** Implement discriminated failures; wrap service-account bind failure distinctly; per-operation timeout (`ConnectionTimeoutMs`); username trim-normalization once at entry. **Step 4:** run → PASS. **Step 5:** `git commit -am "feat(ldap): fail-closed failure modes + distinct service-account errors"` ### Task 7: GLAuth integration test (skippable) **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 8–14 **Blocked by:** Task 6 **Files:** - Test: `tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/GLAuthIntegrationTests.cs` Use `[SkippableFact]` — `Skip.IfNot(GLAuth reachable on localhost:3893)`. Authenticate a known GLAuth user end-to-end through the real `NovellLdapConnection`; assert groups resolve. Document running GLAuth (reuse a sister repo's `infra/glauth`). Commit `test(ldap): skippable GLAuth integration test`. --- ## Phase 3 — ApiKeys (machine auth) — parallel with Phase 2 ### Task 8: Token parser + secret generator **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** Task 3–7 **Files:** - Create: `src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyParser.cs`, `ApiKeySecretGenerator.cs` - Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyParserTests.cs`, `ApiKeySecretGeneratorTests.cs` **Step 1: Failing tests:** parse `mxgw_alice_SECRET` → `(KeyId="alice", Secret="SECRET")`; reject missing prefix / wrong prefix / malformed; prefix configurable. Generator returns 32-byte URL-safe base64, distinct each call (vary by calling twice). **Steps 2-5:** port from mxaccessgw `ApiKeyParser.cs`/`ApiKeySecretGenerator.cs`; TDD red→green; commit `feat(apikeys): token parser + secret generator`. ### Task 9: Peppered HMAC hasher + constant-time compare **Classification:** small (security-sensitive) **Estimated implement time:** ~4 min **Parallelizable with:** Task 3–7 **Files:** - Create: `src/ZB.MOM.WW.Auth.ApiKeys/ApiKeySecretHasher.cs` - Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeySecretHasherTests.cs` **Step 1: Failing tests:** same secret+pepper → identical hash; different pepper → different hash; verification uses `CryptographicOperations.FixedTimeEquals`; missing pepper throws `PepperUnavailable`-style. **Step 3:** HMAC-SHA256 over the secret keyed by the pepper (resolved from `ApiKeyOptions.PepperSecretName` via injected secret provider). Port from mxaccessgw `ApiKeySecretHasher.cs`. **Step 5:** commit `feat(apikeys): peppered HMAC-SHA256 hasher + constant-time compare`. ### Task 10: SQLite schema + connection factory + migrator **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 3–7 **Files:** - Create: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/AuthSqliteConnectionFactory.cs`, `SqliteAuthSchema.cs`, `SqliteAuthStoreMigrator.cs` - Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteMigratorTests.cs` **Step 1: Failing tests:** migrator on a temp DB creates `api_keys`, `api_key_audit`, `schema_version`; idempotent (run twice = no error); refuses a newer on-disk schema. Use a temp-file DB per test, deleted on dispose. **Step 3:** WAL + busy_timeout; schema v1 = the three tables from mxaccessgw `SqliteAuthSchema.cs` (`api_keys`: key_id PK, key_prefix, secret_hash BLOB, display_name, scopes TEXT(json), constraints TEXT NULL, created_utc, last_used_utc NULL, revoked_utc NULL). **Step 5:** commit `feat(apikeys): sqlite schema + connection factory + migrator`. ### Task 11: SqliteApiKeyStore (read + mark-used) **Classification:** small **Estimated implement time:** ~4 min **Parallelizable with:** Task 3–7 **Blocked by:** Task 10 **Files:** - Create: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs` - Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs` Tests: `FindByKeyIdAsync` returns inserted record incl. revoked; `FindActiveByKeyIdAsync` filters revoked; `MarkUsedAsync` updates `last_used_utc` only for active keys. Implement `IApiKeyStore`. Commit `feat(apikeys): sqlite read store + mark-used`. ### Task 12: SqliteApiKeyAdminStore + audit **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 3–7 **Blocked by:** Task 11 **Files:** - Create: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyAdminStore.cs`, `SqliteApiKeyAuditStore.cs` - Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyAdminStoreTests.cs` Tests: create→find; revoke sets `revoked_utc`; rotate replaces hash + clears revoked/last-used; delete only when revoked; every op appends an audit row (`ListRecentAsync`). Implement `IApiKeyAdminStore` + append-only audit. Commit `feat(apikeys): admin store (create/revoke/rotate/delete) + audit`. ### Task 13: ApiKeyVerifier pipeline **Classification:** high-risk **Estimated implement time:** ~5 min **Parallelizable with:** Task 3–7 **Blocked by:** Task 8, Task 9, Task 11 **Files:** - Create: `src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs` - Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyVerifierTests.cs` **Step 1: Failing tests** for the full pipeline + each discriminated failure: `MissingOrMalformed`, `KeyNotFound`, `KeyRevoked`, `PepperUnavailable`, `SecretMismatch`, and success → `ApiKeyIdentity` (with scopes + opaque constraints, **no secret**). Assert success marks the key used. **Step 3:** parse → `FindByKeyIdAsync` → reject revoked → hash with pepper → `FixedTimeEquals` → `MarkUsedAsync`. Port from mxaccessgw `ApiKeyVerifier.cs`. Opaque error to caller, discriminated reason for audit. **Step 5:** commit `feat(apikeys): verification pipeline with discriminated failures`. ### Task 14: Admin command set **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** Task 3–7 **Blocked by:** Task 12 **Files:** - Create: `src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs` (+ parser/result types) - Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyAdminCommandsTests.cs` Reusable verb handlers (`init-db`, `create-key`, `list-keys`, `revoke-key`, `rotate-key`, `delete-key`) returning structured results (so each consumer wires its own CLI front-end). `create-key` prints the assembled token once. Port from mxaccessgw `ApiKeyAdminCliRunner.cs`. Commit `feat(apikeys): reusable admin command set`. --- ## Phase 4 — AspNetCore ### Task 15: Claim types, cookie defaults, DI helpers **Classification:** standard **Estimated implement time:** ~5 min **Parallelizable with:** none **Blocked by:** Task 5, Task 13 **Files:** - Create: `src/ZB.MOM.WW.Auth.AspNetCore/ZbClaimTypes.cs`, `ZbCookieDefaults.cs`, `ServiceCollectionExtensions.cs` - Test: `tests/ZB.MOM.WW.Auth.AspNetCore.Tests/ServiceCollectionExtensionsTests.cs` **Step 1: Failing tests:** `AddZbLdapAuth(config)` binds `LdapOptions`, registers `ILdapAuthService` + validator (resolve from the built provider, assert non-null + options bound). `AddZbApiKeyAuth(config)` registers `IApiKeyVerifier` + stores + runs migrations flag. `ZbCookieDefaults` = HttpOnly, SameSite=Strict, configurable Secure, sliding idle. `ZbClaimTypes` defines name/display/username/role/scope-id constants. **Steps 3-5:** implement DI extensions; TDD; commit `feat(aspnetcore): claim types, cookie defaults, DI helpers`. --- ## Phase 5 — Packaging ### Task 16: Pack metadata, produce nupkgs, publish script + README **Classification:** small **Estimated implement time:** ~5 min **Parallelizable with:** none **Blocked by:** Task 7, Task 14, Task 15 **Files:** - Modify: the 4 library `.csproj` (PackageId, Description, Authors, RepositoryUrl, `true`) - Create: `build/pack.sh`, `build/push.sh`, `README.md` **Steps:** 1. Add package metadata to each lib csproj; ensure inter-package deps surface as NuGet deps (`.Ldap`→`.Abstractions`, etc.). 2. `dotnet pack -c Release -o ./artifacts` → expect **4** `.nupkg` (Abstractions, Ldap, ApiKeys, AspNetCore) at version `0.1.0`. 3. `build/push.sh`: `dotnet nuget push ./artifacts/*.nupkg --source --api-key $GITEA_NUGET_KEY` (creds from env; do not hardcode). 4. `README.md`: package table, consumer matrix (OtOpcUa: Abstractions+Ldap+AspNetCore; gw & SB: all four; ApiKeys not OtOpcUa), versioning (lockstep), "library not service" note. Link the design docs in `scadaproj/components/auth/`. 5. `dotnet test` (full suite green, integration skipped if no LDAP). 6. **Commit:** `git commit -am "build: package metadata, pack/push scripts, README"` **Acceptance:** `dotnet pack` emits 4 nupkgs; full unit suite green. --- ## Out of scope (separate follow-on plan) Consumer **adoption** — migrating OtOpcUa / mxaccessgw / ScadaBridge onto these packages, the `canonical → native` role mappers per project, and config-key migration — is tracked in `scadaproj/components/auth/GAPS.md` (backlog #8) and warrants its own plan once `ZB.MOM.WW.Auth` `0.1.0` is published. ## Suggested execution order `T1 → T2`, then the two chains in parallel: **Ldap** `T3,T4 → T5 → T6 → T7` and **ApiKeys** `T8,T9,T10 → T11 → T12 → T13`, `T14` after `T12`. Then `T15` (needs T5 + T13), then `T16` (needs T7 + T14 + T15).