- Over-length key records (key_id > 64, prefix > 16, display name > 256) are refused with ArgumentException before connecting; an over-64 key-id lookup or mutation answers not-found without querying (SqlClient silently truncated the sized parameter, so a different id sharing the first 64 characters matched). - Every key_id predicate adds an exact VARBINARY comparison, so lookups are case- and trailing-space-exact like SQLite; the PK seek is unchanged and there is no schema change. - SqliteApiKeyStoreImporter reports case-only key-id collisions and over-length rows in new init lists (KeyIdCollisions, KeysRejected) instead of counting them as skips or importing them truncated. - Audit text truncation kept deliberately and documented. Offline refused-port tests and live SQL Server 2022 tests fail pre-fix; SqlServer suite 101/101 live. Auth findings now 0 open; CLAUDE.md records the #46/#50 work; code-reviews/README.md regenerated. (scadaproj#46) Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
16 KiB
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 = Useronly. The bind name (UserBindTemplate) and the group search (UserNameAttribute) are two separate directory lookups and could name two accounts — e.g. AD resolvesjsmith@corp.comto 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'su:DOMAIN\nameanswer must equal the entry'ssAMAccountName(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.
- it asks the directory with RFC 4532 Who-am-I after the bind; a
{0}is now RFC 4514-escaped in a DN template, so a typed name such asx,ou=Adminscannot 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
sAMAccountNameinstead of risking the wrong account. A non-AD directory inUsermode 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 iscn={0},ou=<group>,ou=users,<base>, notcn={0},ou=<group>,<base>. ServiceAccount mode is unchanged. - The validator's
UserBindTemplatemessage now leads withEXAMPLE\{0}. NoAbstractionscontract change (XML docs only). - AD forests (scadaproj#50).
sAMAccountNameis unique per domain, so with a search base that spans a forest (a Global Catalog) a matchingsAMAccountNamealone could be another domain's account of the same name. For AD'su:DOMAIN\nameanswer the entry must now also be proven to be that domain's account:- where the directory computes
msDS-PrincipalNamefor the entry (a base read; AD'sDOMAIN\name, derived fromobjectSid), it must equal the Who-am-I answer, domain included; - otherwise the name must have exactly one holder under
SearchBase, and it must be the entry found — no extra search is made whenUserNameAttributeissAMAccountNameand the user typed the bound name, because the user search already proved that. Two holders →GroupLookupFailed. - A single-domain AD (BTDB Manager) is unaffected. Not yet live-verified against AD — the fake connection covers the paths; GLAuth has no Who-am-I.
- where the directory computes
ApiKeyFailure.StoreUnavailable(Auth-010,Abstractionsaddition).ApiKeyVerifier.VerifyAsyncno longer lets a key-store fault escape: aFindByKeyIdAsyncthat throws (shared SQL Server unreachable, a SQLite I/O fault) returnsSucceeded = falsewith the newStoreUnavailablefailure, so it takes the host's normal — audited — auth-failure path instead of an unhandled 500 / gRPCUnknown. Cancellation still throws. The value is appended, so existing numeric values are unchanged; a host may answer it with 503 rather than 401. A misconfiguredSqlServerApiKeyOptionsstill throws when the store is first resolved (outsideVerifyAsync).- SQL Server store: no truncation, exact key ids (Auth-008). The
ApiKeys.SqlServerstores now behave like the SQLite store they port:CreateAsyncthrowsArgumentExceptionfor a key id over 64, prefix over 16 or display name over 256 characters (it used to store them silently truncated), and every lookup of an over-64-character key id answers "not found" without querying (a truncated parameter could match a different key sharing its first 64 characters).- Key-id matching is exact — case- and trailing-space-sensitive — via a binary comparison added to every
key_idpredicate; the primary-key seek is unchanged and there is no schema change. A token presented with a case-altered key id (LINE1forline1) now failsKeyNotFoundinstead of resolving. SqliteApiKeyStoreImporterreports instead of hiding:ApiKeyImportResult.KeyIdCollisionslists source keys whose id differs only in case from one already in SQL Server (the primary key follows the database collation and cannot hold both — re-mint those keys), andKeysRejectedlists over-length keys; neither is counted inKeysSkippedany more. Both are newinitproperties, so existing code is unaffected.- Audit text (
key_id,event_type,remote_address) is still truncated to 64 characters — deliberately, and now documented: an audit append runs after the action it records and must not fail it.
- Canonical username from a multi-valued attribute (Auth-009).
LdapAuthResult.Usernameis now theUserNameAttributevalue that equals the typed name ignoring case —cn: [John Smith, jsmith]signsjsmithin asjsmith, not whichever value the server lists first. The first value is used only when none matches. Both bind modes.
0.4.1 — LDAP review fixes
- Canonical username (both modes): the search now also requests
UserNameAttribute, andLdapAuthResult.Usernameis the entry's first value of it (the directory's spelling), soJDoeandjdoesign 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 asBadCredentialsbefore any network call, in both bind modes. Previously onlyUsermode refused it; inServiceAccountmode 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
UserBindTemplateEXAMPLE\{0}— it maps exactly to thesAMAccountNamethe group search uses. A UPN template ({0}@example.com) is only correct where every account's UPN prefix equals itssAMAccountName; AD resolves UPN binds against the explicituserPrincipalName, so a mismatch can fail sign-in or bind a different account than the one whose groups are read. - No
Abstractionscontract change.
0.4.0 — LDAP sign-in by user bind (opt-in)
LdapOptions.BindMode(ServiceAccountdefault |User),UserBindTemplate({0}= the login name, e.g.EXAMPLE\{0}— preferred, see 0.4.1 — or{0}@example.com) andAcceptDomainQualifiedNames(defaulttrue).- In
UsermodeLdapAuthServicebinds as the user; success authenticates them and the same connection reads their entry (UserNameAttribute,DisplayNameAttribute,GroupAttribute). NoServiceAccountDn/ServiceAccountPasswordis needed or validated. - Failure mapping: empty password or an unusable name →
BadCredentialswith 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@suffixare reduced toname(or refused whenAcceptDomainQualifiedNamesisfalse), so the result'sUsernamehas one spelling (0.4.1 also canonicalises its case from the entry).- No
Abstractionscontract change beyond the new enum and option properties;ServiceAccountmode is unchanged. UserBindTemplateoutput 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 thesAMAccountName) template; a DN template is not escaped.
0.3.0 — SQL Server API-key store (ScadaBridge central pair)
IApiKeyStoreMigratoradded toAbstractions: creates or upgrades an API-key store's schema; idempotent and safe to run concurrently from several nodes.SqliteAuthStoreMigratorimplements it unchanged.AddZbApiKeyAuthregisters the SQLite implementation viaTryAdd, 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 ofAddZbApiKeyAuth(it calls it internally after registering the SQL Server stores and migrator first). zbauthschema v1 (three tables:schema_version,api_keys,api_key_audit;DATETIMEOFFSET(7)timestamps).SqlServerApiKeyStoreMigratoris 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.ApiKeyAdminCommandsgained a ctor takingIApiKeyStoreMigrator; the existing ctor taking the concreteSqliteAuthStoreMigratoris 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_CONNSTRand skip (not fail) when it is unset.
0.2.1 — LDAP AD continuation-referral fix
NovellLdapConnection.Searchnow skips Active Directory continuation referrals (LdapReferralExceptionfromNext()) instead of letting one fail the whole search — aSearchBaseat a domain root made every login fail asServiceAccountBindFailedagainst 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/ApiKeyListItemgain an optionalDateTimeOffset? ExpiresUtc(nullable;NULL= never expires).ApiKeyFailuregainsKeyExpired.ApiKeyVerifierenforces expiry — a key whoseExpiresUtcis at or before "now" (inclusive, injected clock) fails withKeyExpired, decided before the secret comparison. Existing (never-expiring) keys are unaffected.ApiKeyAdminCommands.CreateKeyAsynchas a new overload takingDateTimeOffset? expiresUtc; the old signature delegates withnull.RotateKeyAsyncdeliberately preserves an existing expiry (rotation changes only the secret).- SQLite schema → v3: a nullable
expires_utccolumn, added to fresh DBs by the CREATE DDL and to existing v1/v2 DBs by an idempotentALTER TABLEin the migrator (existing rows →NULL). A donor v2gateway-auth.dbis 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:
-
Start the GLAuth Docker stack from the sibling repo:
cd ~/Desktop/ScadaBridge/infra/glauth docker compose up -d -
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