Files
scadaproj/ZB.MOM.WW.Auth
Joseph Doherty 9ec0da1b2c fix(auth): canonical username is the attribute value the user matched (Auth-009)
ExtractUserName returned the first value of a possibly multi-valued
UserNameAttribute (default cn), so jsmith could sign in as "John Smith"
depending on server ordering. It now prefers the value equal to the typed
name ignoring case (still canonicalising case) and falls back to the first
value only when none matches.

(scadaproj#46)

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 15:18:25 -05:00
..

ZB.MOM.WW.Auth

Authentication and authorisation libraries for the ZB.MOM.WW SCADA family (OtOpcUa, MxAccessGateway, ScadaBridge). These are libraries, not a service — each package is linked directly into the consuming application at build time. There is no central authentication process or network hop; auth logic runs in-process alongside the application.


Packages

Package Description Key Dependencies
ZB.MOM.WW.Auth.Abstractions Auth contracts, canonical role constants, and shared types (LdapOptions, LdapAuthResult, ILdapAuthService, IApiKeyStore). No runtime dependencies beyond the BCL. —
ZB.MOM.WW.Auth.Ldap LDAP authentication service: bind-then-search-then-bind against GLAuth or Active Directory; RFC 4514-aware group extraction; fail-closed. Abstractions, Novell.Directory.Ldap.NETStandard
ZB.MOM.WW.Auth.ApiKeys SQLite-backed API-key store with pepper-keyed HMAC-SHA256 secret hashing, rotation, and audit log. DI wiring is AddZbApiKeyAuth; an opt-in MigrationHostedService runs schema migrations on startup. Abstractions, Microsoft.Data.Sqlite
ZB.MOM.WW.Auth.ApiKeys.SqlServer SQL Server-backed API-key store sharing one row set across clustered hosts (e.g. a central pair), instead of each node keeping its own SQLite file. DI wiring is AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection), called instead of AddZbApiKeyAuth (it calls that internally). Also ships SqliteApiKeyStoreImporter — a one-shot per-node SQLite → shared store copy a host runs once at boot. Abstractions, ApiKeys, Microsoft.Data.SqlClient
ZB.MOM.WW.Auth.AspNetCore ASP.NET Core wiring for the LDAP provider only: AddZbLdapAuth (binds + start-time-validates LdapOptions, registers ILdapAuthService), plus ZbCookieDefaults.Apply (hardened cookie helper the consumer calls itself) and ZbClaimTypes constants. It does not wire API keys or cookie middleware — API-key DI is AddZbApiKeyAuth in the ApiKeys package. Abstractions, Ldap, Microsoft.AspNetCore.App

Consumer Matrix

Consumer Abstractions Ldap ApiKeys ApiKeys.SqlServer AspNetCore
OtOpcUa yes yes — — yes
MxAccessGateway yes yes yes — yes
ScadaBridge yes yes yes yes (central pair, 2026-09-02) yes

ApiKeys is NOT used by OtOpcUa (that app authenticates human operators via LDAP + cookies only; machine-to-machine access is out of scope).


Versioning

All five packages are versioned lockstep from Directory.Build.props. The current release is 0.4.1. A single version bump in Directory.Build.props bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.

0.4.4 — user bind proves the bound account is the one whose groups it returns (unpublished, scadaproj#40)

  • Security fix (Auth-007), BindMode = User only. The bind name (UserBindTemplate) and the group search (UserNameAttribute) are two separate directory lookups and could name two accounts — e.g. AD resolves jsmith@corp.com to the account with that explicit UPN while (sAMAccountName=jsmith) finds a different one, so a user could sign in with another account's groups. The service now only accepts the entry it found when it is provably the account that bound:
    • it asks the directory with RFC 4532 Who-am-I after the bind; a dn:<dn> answer must equal the entry's DN (distinguishedNameMatch), and AD's u:DOMAIN\name answer must equal the entry's sAMAccountName (requested automatically);
    • a server without Who-am-I (GLAuth answers protocol error 2) is supported only with a DN template (contains =), whose bind DN must equal the entry's DN;
    • anything else — a mismatch, an unanswered Who-am-I with a UPN or down-level template — is GroupLookupFailed, logged at Warning, and never retried on a fallback endpoint.
  • {0} is now RFC 4514-escaped in a DN template, so a typed name such as x,ou=Admins cannot rewrite the bind DN.
  • Behaviour change for consumers: AD with a down-level or UPN template is unaffected for accounts whose bound identity and searched entry agree (AD has supported Who-am-I since Windows Server 2003). A UPN template now refuses accounts whose UPN prefix differs from sAMAccountName instead of risking the wrong account. A non-AD directory in User mode needs either Who-am-I support or a DN template that yields the entry's DN exactly as the search returns it — for GLAuth that is cn={0},ou=<group>,ou=users,<base>, not cn={0},ou=<group>,<base>. ServiceAccount mode is unchanged.
  • The validator's UserBindTemplate message now leads with EXAMPLE\{0}. No Abstractions contract change (XML docs only).

0.4.1 — LDAP review fixes

  • Canonical username (both modes): the search now also requests UserNameAttribute, and LdapAuthResult.Username is the entry's first value of it (the directory's spelling), so JDoe and jdoe sign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value.
  • Behaviour change for ServiceAccount-mode consumers: an empty (or null) password is now refused by the library as BadCredentials before any network call, in both bind modes. Previously only User mode refused it; in ServiceAccount mode the re-bind as the user's DN with an empty password is an unauthenticated bind that AD reports as success, so a consumer without its own edge check admitted anyone.
  • Template guidance: prefer the down-level UserBindTemplate EXAMPLE\{0} — it maps exactly to the sAMAccountName the group search uses. A UPN template ({0}@example.com) is only correct where every account's UPN prefix equals its sAMAccountName; AD resolves UPN binds against the explicit userPrincipalName, so a mismatch can fail sign-in or bind a different account than the one whose groups are read.
  • No Abstractions contract change.

0.4.0 — LDAP sign-in by user bind (opt-in)

  • LdapOptions.BindMode (ServiceAccount default | User), UserBindTemplate ({0} = the login name, e.g. EXAMPLE\{0} — preferred, see 0.4.1 — or {0}@example.com) and AcceptDomainQualifiedNames (default true).
  • In User mode LdapAuthService binds as the user; success authenticates them and the same connection reads their entry (UserNameAttribute, DisplayNameAttribute, GroupAttribute). No ServiceAccountDn / ServiceAccountPassword is needed or validated.
  • Failure mapping: empty password or an unusable name → BadCredentials with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → BadCredentials, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (ServiceAccountBindFailed) and the next endpoint. Zero/several entries or no groups after a good bind → GroupLookupFailed.
  • DOMAIN\name / name@suffix are reduced to name (or refused when AcceptDomainQualifiedNames is false), so the result's Username has one spelling (0.4.1 also canonicalises its case from the entry).
  • No Abstractions contract change beyond the new enum and option properties; ServiceAccount mode is unchanged.
  • UserBindTemplate output is used verbatim as the bind name, so use a down-level (EXAMPLE\{0}, preferred) or UPN ({0}@example.com, only where every UPN prefix equals the sAMAccountName) template; a DN template is not escaped.

0.3.0 — SQL Server API-key store (ScadaBridge central pair)

  • IApiKeyStoreMigrator added to Abstractions: creates or upgrades an API-key store's schema; idempotent and safe to run concurrently from several nodes. SqliteAuthStoreMigrator implements it unchanged. AddZbApiKeyAuth registers the SQLite implementation via TryAdd, so a provider that registers its own migrator first (see below) wins — that ordering is the whole mechanism by which a different backend is swapped in.
  • New package ZB.MOM.WW.Auth.ApiKeys.SqlServer + AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection), called instead of AddZbApiKeyAuth (it calls it internally after registering the SQL Server stores and migrator first).
  • zbauth schema v1 (three tables: schema_version, api_keys, api_key_audit; DATETIMEOFFSET(7) timestamps). SqlServerApiKeyStoreMigrator is concurrent-boot safe — a central pair's two nodes both migrate at every boot — with retry on SQL error numbers 1205/2714/1913/2627 and jittered backoff (measured: the flat backoff it replaced exhausted its retries roughly 1 in 60 eight-way boots). DBA-facing DDL: docs/sql/zbauth-schema.sql.
  • SqliteApiKeyStoreImporter — idempotent insert-where-not-exists copy of an existing node's per-node SQLite keys and audit rows into the shared store; a row already present in SQL Server is never modified, so running it from both nodes of a pair converges on the union of their files.
  • ApiKeyAdminCommands gained a ctor taking IApiKeyStoreMigrator; the existing ctor taking the concrete SqliteAuthStoreMigrator is retained (MxAccessGateway, HistorianGateway and ScadaBridge all construct it that way).
  • SQLite remains the default store; existing consumers need no code change to move to 0.3.0.
  • Live tests for the SQL Server package are gated on AUTH_SQLSERVER_CONNSTR and skip (not fail) when it is unset.

0.2.1 — LDAP AD continuation-referral fix

  • NovellLdapConnection.Search now skips Active Directory continuation referrals (LdapReferralException from Next()) instead of letting one fail the whole search — a SearchBase at a domain root made every login fail as ServiceAccountBindFailed against a healthy directory. Non-referral faults still propagate untouched.

0.2.0 — version bump only

  • No functional change; bumped the lockstep family version ahead of the 0.2.1 LDAP fix.

0.1.4 — API-key expiry (archreview G-2)

  • ApiKeyRecord/ApiKeyListItem gain an optional DateTimeOffset? ExpiresUtc (nullable; NULL = never expires). ApiKeyFailure gains KeyExpired.
  • ApiKeyVerifier enforces expiry — a key whose ExpiresUtc is at or before "now" (inclusive, injected clock) fails with KeyExpired, decided before the secret comparison. Existing (never-expiring) keys are unaffected.
  • ApiKeyAdminCommands.CreateKeyAsync has a new overload taking DateTimeOffset? expiresUtc; the old signature delegates with null. RotateKeyAsync deliberately preserves an existing expiry (rotation changes only the secret).
  • SQLite schema → v3: a nullable expires_utc column, added to fresh DBs by the CREATE DDL and to existing v1/v2 DBs by an idempotent ALTER TABLE in the migrator (existing rows → NULL). A donor v2 gateway-auth.db is accepted (2 ≤ 3) and upgraded in place — no key is invalidated. Old-build readers tolerate the extra column.
  • Consumers must bump to 0.1.4 to set/enforce expiry (mxaccessgw + HistorianGateway benefit from the same verifier).

Running the opt-in LDAP integration test

The GLAuth integration test (GLAuthIntegrationTests) is skipped by default and does not affect the normal test run. To exercise it against a live GLAuth instance:

  1. Start the GLAuth Docker stack from the sibling repo:

    cd ~/Desktop/ScadaBridge/infra/glauth
    docker compose up -d
    
  2. Set the required environment variables and run the test:

    export ZB_LDAP_IT=1
    export ZB_LDAP_SVC_DN="cn=svc,dc=lmxopcua,dc=local"
    export ZB_LDAP_SVC_PW="svcpass"
    export ZB_LDAP_USER="alice"
    export ZB_LDAP_PW="alicepass"
    
    dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests \
      --filter "FullyQualifiedName~GLAuthIntegrationTests"
    

All other variables (ZB_LDAP_SERVER, ZB_LDAP_PORT, ZB_LDAP_BASE, ZB_LDAP_USERATTR) default to sensible GLAuth values and are optional. The test also probes TCP reachability before attempting auth and skips if the server is not contactable.


Publishing packages

Use build/push.sh to pack and push to the Gitea NuGet feed:

export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
export GITEA_NUGET_KEY="your-gitea-token"
./build/push.sh

The script runs dotnet pack -c Release then dotnet nuget push --skip-duplicate.


Design documentation

Full design docs live in the components/auth folder of the SCADA project notes:

  • ~/Desktop/scadaproj/components/auth/spec/SPEC.md — overall auth specification
  • ~/Desktop/scadaproj/components/auth/spec/CANONICAL-ROLES.md — role taxonomy
  • ~/Desktop/scadaproj/components/auth/shared-contract/ — shared contract types