Files
scadaproj/docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md
T

19 KiB
Raw Blame History

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:

namespace ZB.MOM.WW.Auth.Abstractions.Roles;
public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator }

Step 2: Builddotnet build src/ZB.MOM.WW.Auth.Abstractions. Expected: success.

Step 3: Commitgit 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):

[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 814 (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):

[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 814 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), GroupLookupFailedmust 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 814 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 37

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 37

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 37

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 37 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 37 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 37 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 → FixedTimeEqualsMarkUsedAsync. 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 37 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

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).