19 KiB
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:
cd ~/Desktop && mkdir ZB.MOM.WW.Auth && cd ZB.MOM.WW.Auth && git init && dotnet new gitignoredotnet new sln -n ZB.MOM.WW.Auth --format slnx(if.slnxunsupported by the SDK, use default.sln).- Scaffold projects:
dotnet new classlib -f net10.0 -o src/<Name>for the 4 libs;dotnet new xunit -f net10.0 -o tests/<Name>.Testsfor the 3 test projects. Delete the defaultClass1.cs/UnitTest1.cs. - Project refs:
.Ldap/.ApiKeys/.AspNetCore→.Abstractions;.AspNetCore→.Ldap+.ApiKeys; each test project → its lib (+.Abstractions). 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>.Directory.Packages.props: pinNovell.Directory.Ldap.NETStandard,Microsoft.Data.Sqlite,Microsoft.Extensions.Options,Microsoft.Extensions.DependencyInjection.Abstractions,Microsoft.AspNetCore.Authentication.Cookies/.JwtBearer,Microsoft.AspNetCore.Authorization, and test packagesxunit,xunit.runner.visualstudio,Xunit.SkippableFact,Microsoft.NET.Test.Sdk.dotnet sln addall projects;dotnet buildto confirm the empty solution compiles.- 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: 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):
[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(wrapsNovell.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):
[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:
- Add package metadata to each lib csproj; ensure inter-package deps surface as NuGet deps (
.Ldap→.Abstractions, etc.). dotnet pack -c Release -o ./artifacts→ expect 4.nupkg(Abstractions, Ldap, ApiKeys, AspNetCore) at version0.1.0.build/push.sh:dotnet nuget push ./artifacts/*.nupkg --source <gitea-feed> --api-key $GITEA_NUGET_KEY(creds from env; do not hardcode).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 inscadaproj/components/auth/.dotnet test(full suite green, integration skipped if no LDAP).- 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).