# 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/<Name>` for the 4 libs; `dotnet new xunit -f net10.0 -o tests/<Name>.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`: `<TargetFramework>net10.0</TargetFramework>`, `<Nullable>enable</Nullable>`, `<ImplicitUsings>enable</ImplicitUsings>`, `<LangVersion>latest</LangVersion>`, `<Version>0.1.0</Version>` (lockstep), `<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>`.
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<TRole>`, `GroupRoleMapping<TRole>`)
- 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<IReadOnlyList<LdapEntry>> 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, `<IsPackable>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 <gitea-feed> --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).
