# Code Review — Auth
| Field | Value |
|-------|-------|
| Library | `ZB.MOM.WW.Auth/` |
| Packages | `ZB.MOM.WW.Auth.Abstractions`, `ZB.MOM.WW.Auth.Ldap`, `ZB.MOM.WW.Auth.ApiKeys`, `ZB.MOM.WW.Auth.AspNetCore` |
| Component spec | `components/auth/spec/SPEC.md` |
| Shared contract | `components/auth/shared-contract/ZB.MOM.WW.Auth.md` |
| Status | Reviewed |
| Last reviewed | 2026-11-09 (re-review; baseline 2026-06-01 at `5f75cd4`) |
| Reviewer | Claude (2026-11-09 re-review, scadaproj#15; baseline: Claude automated) |
| Commit reviewed | `91fb2b4` |
| Open findings | 0 |
## Summary
**2026-11-09 re-review (`91fb2b4`, scadaproj#15).** Re-reviewed at `91fb2b4` (on-disk version `0.4.3`; the feed carries `0.4.1`), covering everything added
since the `5f75cd4` baseline: LDAP `FallbackServers` failover with a sticky preferred endpoint (0.2.0), the AD
continuation-referral skip (0.2.1), the new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package (stores, migrator that
copes with concurrent boots, SQLite importer, `AddZbApiKeyAuthSqlServer`) (0.3.0), opt-in LDAP user bind
(`BindMode=User` / `UserBindTemplate` / `AcceptDomainQualifiedNames`) (0.4.0), and the canonical username plus
empty-password refusal (0.4.1). Auth-001..006 remain correctly resolved, and the new code holds up on its main
security points:
- **One connection per attempt.** Every endpoint attempt gets a fresh `ILdapConnection`, disposed by `using` on
every path, including failover.
- **Failover is system-side only.** A `BadCredentials`/49 answer is final and is never replayed against another
DC.
- **Empty passwords are refused before any network call in both modes**, which closes the unauthenticated-bind
hole.
- **Filter values are still RFC 4515-escaped.**
- **The SQL Server store is parameterised throughout.** The schema name passes a strict ASCII allow-list
re-checked at the public DDL entry point.
- **Concurrent first-boot migration converges.** The retry on 1205/2714/1913/2627 with jitter works: the live
suite passes 92/92 against a throwaway SQL Server 2022, and the DBA script plus a row-only login migrate cleanly.
- **The SQL Server DI extension has container-building tests.**
The main new issue is in user-bind mode. The account that binds and the entry whose groups and username are
returned are found independently: the first from `UserBindTemplate`, the second from a `UserNameAttribute`
search. Nothing checks that they are the same object, so a UPN template, which the validator's own error text
offers first, can sign one user in with another user's groups (Auth-007). The remaining findings are Low:
- **Silent truncation in the SQL Server store.** The store sets explicit `SqlParameter` sizes and uses
case-insensitive key ids, so it does not match SQLite's semantics (Auth-008).
- **Username from a multi-valued attribute.** The canonical username takes the first value of a possibly
multi-valued attribute (Auth-009).
- **Store faults escape the verifier.** `ApiKeyVerifier` still lets store exceptions out, which matters more now
that the store can be a network database (Auth-010).
Offline suite: 331 passed / 76 skipped (live-gated). With `AUTH_SQLSERVER_CONNSTR` set, the SqlServer suite
runs 92/92.
**2026-06-01 baseline (`5f75cd4`).** The security-critical core of this library is sound: API-key secrets are 32-byte CSPRNG values,
hashing is HMAC-SHA256 keyed by an external pepper, verification uses
`CryptographicOperations.FixedTimeEquals`, the SQLite stores use parameterized SQL throughout,
LDAP filter values are RFC 4515-escaped before being interpolated into the search filter, group-DN
parsing is RFC 4514 escape-aware, and the LDAP service is genuinely fail-closed (never throws,
requires exactly one match plus a verified password plus >=1 group for success). Test coverage is
strong and contract-focused (~172 tests; every failure mode, the parser, the hasher, the escaper,
the stores, the migrator and the DI wiring are exercised). The findings below are about
robustness-around-the-edges and documentation, not the cryptographic core. The two themes are
(1) two narrow paths that can turn a recoverable condition into a thrown exception on the
authentication hot path, contradicting the library's own "only exception path is cancellation" /
"fail fast at startup" promises, and (2) public-facing documentation (README) that misstates the
hashing algorithm and the AspNetCore surface. The library is **not yet adopted** by the three apps
(tracked in `components/auth/GAPS.md`).
## Checklist coverage
2026-11-09 re-review at `91fb2b4`:
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ☑ | Failover walk, sticky index, referral skip, `LdapUserName` normaliser and the expiry check are correct. See Auth-008 (silent truncation / case-insensitive key ids diverge from SQLite) and Auth-009 (canonical username = first value of a multi-valued attribute) |
| 2 | Public API surface & compatibility | ☑ | `IApiKeyStoreMigrator` seam is clean and the old `SqliteAuthStoreMigrator` ctor is kept. Two changes are 0.x source/binary breaks with no other effect: the new `IApiKeyAdminStore` members and the optional parameter on `ApiKeyRecord`. No issues found |
| 3 | Concurrency & thread safety | ☑ | The sticky `_preferredIndex` uses `Volatile`, so its race is benign. The SQL Server migrator's retry with jitter converged under the live 8-way test. Stores open one connection per operation. No issues found |
| 4 | Error handling & resilience | ☑ | `LdapAuthService` stays fail-closed on every stage and `MarkUsedAsync` is best-effort (Auth-002 holds). See Auth-010: store lookup faults (e.g. `SqlException` on a shared-DB outage) still escape `ApiKeyVerifier`, despite its "only cancellation" contract |
| 5 | Security & secret handling | ☑ | Empty passwords are refused in both modes, BadCredentials is never failed over, filter escaping is intact, SQL is parameterised and the schema identifier is allow-listed. See Auth-007 (user-bind mode reads a different entry from the one that bound). DN templates are not escaped, but README documents this |
| 6 | Performance & resource management | ☑ | `ILdapConnection` is disposed via `using` inside the per-attempt try (every path, incl. failover). SQL connections, commands and readers use `await using`, and `OpenConnectionAsync` disposes on a failed open. No issues found |
| 7 | Spec & shared-contract adherence | ☑ | SQL Server rows mirror SQLite column-for-column, apart from Auth-008. The `IApiKeyVerifier` "only cancellation" contract is violated (Auth-010) |
| 8 | Packaging, dependencies & project layout | ☑ | New SqlServer package is cleanly split and depends only on Abstractions-level `Microsoft.Extensions.*`. The Pkcs/SQLitePCLRaw transitive pins are present. No issues found |
| 9 | Testing coverage | ☑ | Strong: failover, user-bind, referral, normaliser, validator, live SQL Server suites, plus DI tests that build containers for `AddZbApiKeyAuthSqlServer`, `AddZbApiKeyAuth` and `AddZbLdapAuth`. Gaps: no test ties the bound identity to the returned entry (Auth-007), no over-length or case-variant key-id test (Auth-008), no multi-valued username attribute test (Auth-009), no store-throws verifier test (Auth-010) |
| 10 | Documentation & XML docs | ☑ | Thorough. `UserBindTemplate` docs warn about UPN mismatch, but the validator message offers the UPN form first (Auth-007). `ApiKeyVerifier` remark overstates its exception contract (Auth-010) |
2026-06-01 baseline checklist (5f75cd4)
| # | Category | Examined | Notes |
|---|----------|----------|-------|
| 1 | Correctness & logic bugs | ☑ | Core logic correct; see Auth-003 (JSON parse can throw on hot path), Auth-005 (KeyPrefix value inconsistency) |
| 2 | Public API surface & compatibility | ☑ | Surface is minimal, sealed records, correct nullable annotations, internal seams hidden via `InternalsVisibleTo`. No issues found |
| 3 | Concurrency & thread safety | ☑ | Options/records immutable; services stateless singletons; fresh SQLite connection per op with WAL + busy_timeout. No issues found |
| 4 | Error handling & resilience | ☑ | LDAP path fully fail-closed; but see Auth-001 (no `ValidateOnStart`), Auth-002 (`MarkUsedAsync` failure fails a valid auth), Auth-003 |
| 5 | Security & secret handling | ☑ | Peppered HMAC-SHA256 + FixedTimeEquals, CSPRNG secrets, parameterized SQL, LDAP escaping, hash-free list projection, secrets never logged. See Auth-006 (TLS cert-validation hook) |
| 6 | Performance & resource management | ☑ | Connections disposed via `await using`; `LdapConnection` disposed; no hot-path allocs of note. No issues found |
| 7 | Spec & shared-contract adherence | ☑ | Matches SPEC §1–§5 closely (HMAC-SHA256 pepper, constant-time compare, discriminated failures, cookie hardening). See Auth-001 (insecure-refusal deferred to first use) |
| 8 | Packaging, dependencies & project layout | ☑ | Clean 4-package split, central versions, minimal deps, Abstractions has no runtime deps. No issues found |
| 9 | Testing coverage | ☑ | Broad and contract-level; gaps: no test for corrupt-`scopes` deserialization (Auth-003) or `MarkUsedAsync`-throws (Auth-002); `ValidateOnStart` behavior untested (Auth-001) |
| 10 | Documentation & XML docs | ☑ | XML docs thorough and accurate; README is stale — see Auth-004 |
## Findings
### Auth-001 — LDAP options validator is registered but never runs at startup
| | |
|--|--|
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.AspNetCore/ServiceCollectionExtensions.cs:40` |
**Description**
`AddZbLdapAuth` binds `LdapOptions` with `services.Configure(...)` and registers the
validator with a plain `services.AddSingleton, LdapOptionsValidator>()`.
It never calls `OptionsBuilder.ValidateOnStart()`. With `Microsoft.Extensions.Options`, an
`IValidateOptions` runs only when the options instance is first materialized (`IOptions.Value`),
not at host startup. Here the only consumer that materializes the options is the `ILdapAuthService`
singleton factory (line 48), whose lambda runs on first resolution of `ILdapAuthService` — i.e. on
the first login attempt, not at boot.
The result contradicts the explicit promise in both the method comment ("Fail fast at startup on a
misconfigured directory rather than on first login") and the validator's own XML doc ("Validates
`LdapOptions` at startup so a misconfiguration fails fast at boot"). It also weakens SPEC §2 step 1,
which says an insecure transport without `AllowInsecure` must be *refused* — that refusal is silently
deferred until a user tries to log in. A process can therefore boot green with `Transport=None` and
no `AllowInsecure`, and only surface the misconfiguration as a failed login later. Because this is a
shared library consumed by three hosts, the missing guard affects all of them.
**Recommendation**
Register options via the builder and add `ValidateOnStart`:
```csharp
services.AddOptions()
.Bind(config.GetSection(sectionPath))
.ValidateOnStart();
services.AddSingleton, LdapOptionsValidator>();
```
(`ValidateOnStart` relies on the host's start-time options validation, which the ASP.NET Core hosts
already run.) Add a test asserting that building + starting the host fails for an insecure config.
**Resolution**
Resolved in `544a6dd`, 2026-06-01 — `AddZbLdapAuth` now binds via `AddOptions().Bind(...).ValidateOnStart()` so the validator runs at host start, not on first login (test: `ServiceCollectionExtensionsTests.AddZbLdapAuth_StartingHost_FailsForInsecureConfig`, plus a `...SucceedsForSecureConfig` companion).
### Auth-002 — A failed `MarkUsedAsync` write turns a valid API key into a thrown exception
| | |
|--|--|
| Severity | Medium |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs:66` |
**Description**
After a successful constant-time secret match, `VerifyAsync` does
`await store.MarkUsedAsync(record.KeyId, _timeProvider.GetUtcNow(), ct)` before returning the
success result. `MarkUsedAsync` is a best-effort "last used" bookkeeping write. If that write fails
(SQLite still `SQLITE_BUSY` after the 5 s busy-timeout, disk full, DB locked by a long migration,
etc.), the exception propagates out of `VerifyAsync` and the otherwise-valid credential fails
authentication. The verifier's own class remark states "The only exception path is cancellation",
so callers (gRPC interceptors / middleware) will not be wrapping this in a try/catch and a transient
storage hiccup becomes an auth outage for a legitimate caller.
**Recommendation**
Treat the usage write as best-effort: either swap the order (return success, then fire-and-record),
or wrap the `MarkUsedAsync` call so a failure is logged/swallowed rather than failing the
verification — the authentication decision has already been made by line 60. If `MarkUsedAsync` is
allowed to throw, update the XML remark to say so. Add a test where the store's `MarkUsedAsync`
throws and assert the result is still `Succeeded == true`.
**Resolution**
Resolved in `544a6dd`, 2026-06-01 — `VerifyAsync` now wraps the best-effort `MarkUsedAsync` write in try/catch, re-throwing only `OperationCanceledException` and swallowing all other failures so a valid key still authenticates (tests: `ApiKeyVerifierTests.VerifyAsync_ValidKey_MarkUsedThrows_StillSucceeds` and `..._MarkUsedThrowsOperationCanceled_Propagates`).
### Auth-003 — Corrupt `scopes`/`constraints` column throws `JsonException` through the verifier
| | |
|--|--|
| Severity | Medium |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/ScopeSerializer.cs:31`, `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteApiKeyStore.cs:71` |
**Description**
`SqliteApiKeyStore.ReadRecord` calls `ScopeSerializer.Deserialize(reader.GetString(4))`, which does
`JsonSerializer.Deserialize(value)`. If the `scopes` column ever holds non-array or
malformed JSON (operator tampering, a partial write, a future schema/format change, or a bug in
another writer), `Deserialize` throws `JsonException`. That exception propagates out of
`FindByKeyIdAsync`, and therefore out of `ApiKeyVerifier.VerifyAsync`, as an unhandled exception —
again contradicting the "only exception path is cancellation" contract and converting a single
poisoned row into a crash on the auth path rather than a structured fail-closed result. The same
applies to the admin `ListAsync` projection.
**Recommendation**
Decide the policy explicitly. Either (a) catch `JsonException` in `ScopeSerializer.Deserialize` and
treat a malformed column as an empty/invalid scope set so verification fails closed structurally, or
(b) catch it at the store boundary and surface it as a controlled error. Add a test that inserts a
row with a malformed `scopes` value and asserts the verifier returns a structured failure (or a
documented exception) rather than an unhandled `JsonException`.
**Resolution**
Resolved in `544a6dd`, 2026-06-01 — `ScopeSerializer.Deserialize` now catches `JsonException` and fails closed to an empty scope set (policy (a)), so a corrupt `scopes`/`constraints` column degrades to a zero-scope identity instead of throwing through the store and verifier (tests: `SqliteApiKeyStoreTests.ScopeSerializer_DeserializeMalformed_ReturnsEmptySet_DoesNotThrow` (Theory) and `..._FindByKeyId_CorruptScopesColumn_ReturnsRecordWithEmptyScopes_DoesNotThrow`).
### Auth-004 — README misstates the hashing algorithm and the AspNetCore public surface
| | |
|--|--|
| Severity | Low |
| Category | Documentation & XML docs |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/README.md:13`, `ZB.MOM.WW.Auth/README.md:14` |
**Description**
The README's package table contains two inaccuracies that matter for a security library:
1. It describes `ZB.MOM.WW.Auth.ApiKeys` as a store "with pepper-based **PBKDF2** hashing". The code
uses **HMAC-SHA256** keyed by the pepper (`ApiKeySecretHasher.Hash` → `new HMACSHA256(pepperBytes)`),
not PBKDF2. This is the correct algorithm per SPEC §4 ("HMAC-SHA256 with an external pepper"), so
the *code* is right and the *README* is wrong — but a reader auditing the crypto will be misled
about iteration count / KDF properties that do not exist here.
2. It says `ZB.MOM.WW.Auth.AspNetCore` provides "ASP.NET Core DI helpers (`AddZbAuth`) ... and
`LdapOptionsValidator` registration. **Wires together Ldap + ApiKeys + cookie middleware**." The
actual public surface is a single extension method `AddZbLdapAuth` (LDAP only). There is no
`AddZbAuth`, no API-key re-export from this package, and no cookie-middleware wiring —
`ZbCookieDefaults.Apply` is a helper the consumer must call itself, and API-key DI lives in the
separate `ZB.MOM.WW.Auth.ApiKeys` package (`AddZbApiKeyAuth`). The shared contract
(`ZB.MOM.WW.Auth.md`) likewise lists a combined DI shape that the code splits differently.
**Recommendation**
Fix the README table: change "PBKDF2" to "HMAC-SHA256", and correct the AspNetCore row to describe
the real surface (`AddZbLdapAuth`, `ZbCookieDefaults.Apply`, `ZbClaimTypes`) and note that API-key
wiring is `AddZbApiKeyAuth` in the ApiKeys package. Optionally reconcile the shared-contract doc's
DI-method names with the shipped names.
**Resolution**
Resolved in `544a6dd`, 2026-06-01 — README package table corrected: "PBKDF2" → "pepper-keyed HMAC-SHA256" and the AspNetCore row rewritten to the real surface (`AddZbLdapAuth`, `ZbCookieDefaults.Apply`, `ZbClaimTypes`, LDAP-only, no API-key/cookie-middleware wiring), with the stale `ApiKeys` dependency dropped from that row to match the `.csproj` (test: doc-only).
### Auth-005 — `CreateKeyAsync` persists `KeyPrefix` as `prefix_keyId`, inconsistent with the read path
| | |
|--|--|
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs:104` |
**Description**
When creating a key, `KeyPrefix` is set to `$"{_options.TokenPrefix}_{keyId}"` (e.g. `mxgw_key-1`),
embedding the key id into the column. Elsewhere `KeyPrefix` is treated as the bare token prefix:
the verifier test (`ApiKeyVerifierTests.BuildRecord`) and the store test
(`SqliteApiKeyStoreTests.SampleRecord`) seed it as `"mxgw"` / `"mxgw_ab12"`, and the `ApiKeyRecord`
contract gives no indication it should include the key id. `KeyPrefix` is not consulted during
verification (the token is re-parsed from the header), so there is no security impact — but the
persisted/displayed value is inconsistent and self-referential (the key id is already its own
column), which will confuse admin tooling that surfaces `KeyPrefix`.
**Recommendation**
Persist `KeyPrefix = _options.TokenPrefix` (the bare prefix) and let the key id remain the `KeyId`
column, or document precisely what `KeyPrefix` is meant to contain on the `ApiKeyRecord` /
`ApiKeyListItem` records and align all call sites to it.
**Resolution**
Resolved in `544a6dd`, 2026-06-01 — `CreateKeyAsync` now persists `KeyPrefix = _options.TokenPrefix` (the bare prefix) instead of `prefix_keyId`, aligning the write path with the read/test paths (test: `ApiKeyAdminCommandsTests.CreateKey_PersistsBareTokenPrefix_NotPrefixUnderscoreKeyId`).
### Auth-006 — LDAP connection ignores `allowInsecure` and offers no TLS certificate-validation hook
| | |
|--|--|
| Severity | Low |
| Category | Security & secret handling |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/Internal/NovellLdapConnection.cs:16` |
**Description**
`NovellLdapConnection.Connect` receives `bool allowInsecure` but never uses it; transport-security
correctness rests entirely on `LdapOptionsValidator` having run beforehand (and that registration is
itself lazy — see Auth-001). The method also sets `SecureSocketLayer`/`StartTls` but never configures
a server-certificate validation delegate or any pinning/CA hook. The underlying
`Novell.Directory.Ldap.NETStandard` 3.6.0 validates against the OS trust store by default (it does
*not* blind-accept), so this is not an unauthenticated-TLS hole today — hence Low — but the library
gives a consumer no seam to tighten (pin a CA, validate the SAN against `Server`, or deliberately
accept a self-signed dev cert), and the unused parameter reads as a guard that does nothing.
**Recommendation**
Either remove the unused `allowInsecure` parameter from the `ILdapConnection.Connect` seam or use it
to gate an explicit "accept invalid cert" path for dev only. Add an optional
`RemoteCertificateValidationCallback` (or CA-pinning option) so production consumers can harden LDAPS
beyond the OS default, and document the default validation behavior.
**Resolution**
Resolved in `544a6dd`, 2026-06-01 — added an optional `LdapOptions.ServerCertificateValidationCallback` (BCL `RemoteCertificateValidationCallback`, no new dependency) threaded through the `ILdapConnection.Connect` seam; `NovellLdapConnection` now builds `LdapConnectionOptions.ConfigureRemoteCertificateValidationCallback(...)` (a supplied callback wins; otherwise the previously-dead `allowInsecure` gates a dev-only accept-any path; otherwise OS-trust-store default), and the default validation behaviour is documented in XML/README (tests: `LdapAuthServiceTests.Connect_ReceivesConfiguredCertValidationCallback`, `..._Connect_ReceivesAllowInsecureFlag_FromOptions`, `..._Connect_NoCertCallbackConfigured_PassesNull`).
### Auth-007 — User-bind mode never checks that the entry whose groups it returns is the account that bound
| | |
|--|--|
| Severity | Medium |
| Priority | P2 |
| Category | Security & secret handling |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs:329` |
**Description**
`CompleteAsUser` authenticates by binding as `UserBindTemplate.Replace("{0}", username)` (line 329–332). It then
runs a separate search, `(=)` (line 345 → `SearchUser`, line 361), and returns the
groups and username of whatever single entry that search finds (line 349 → `SucceedFromEntry`). Two different
directory lookups resolve the bind name and the search filter. Nothing checks that the entry found is the account
the directory just authenticated. There is no comparison of DNs, no RFC 4532 "Who am I?", and no search keyed on
the bind identity. The XML doc and README describe the UPN mismatch as guidance only ("a mismatch can … bind a
different account than the one whose groups are read"). The validator's failure message
(`LdapOptionsValidator.cs:77`) offers `"{0}@example.com"` as its first example template. A DN-form template is also
spliced in unescaped (documented), which is another way for the bind identity and the search key to diverge.
**Failure scenario**
`BindMode=User` with `UserBindTemplate="{0}@corp.com"` and `UserNameAttribute=sAMAccountName` on AD, a combination
the validator message suggests. Mallory's account has explicit UPN `jsmith@corp.com` and `sAMAccountName=jsmith2`,
which is common after a rename or when two people share a name. A separate admin account has `sAMAccountName=jsmith`.
Mallory signs in as `jsmith` with her own password:
1. AD resolves the UPN bind to Mallory's account and accepts it.
2. The search `(sAMAccountName=jsmith)` returns the admin's entry.
3. `LdapAuthResult.Success("jsmith", "Admin Smith", ["UG_Admins"])` is returned.
That is privilege escalation with valid credentials of a different user. I reproduced this through the
`FakeLdapConnection` seam: the bind went to `jsmith@corp.test` and the result carried the other entry's groups.
**Recommendation**
After the bind, identify the entry by the bound identity, not the typed name:
- **UPN templates:** search on `userPrincipalName=`.
- **Any template:** use the RFC 4532 Who-am-I extended operation (AD returns `u:DOMAIN\sam`) and check it against
the found entry's `sAMAccountName`.
- **DN templates:** require the found entry's DN to equal the bind DN, and DN-escape `{0}` (RFC 4514).
Fail with `GroupLookupFailed` on a mismatch. At minimum, reorder the validator example to lead with
`EXAMPLE\{0}`. Add a test where the bind name and the searched entry differ.
**Resolution**
Resolved 2026-11-09 (scadaproj#40, Auth `0.4.4`, unpublished). Re-verified first: through the fake seam, the pre-fix
service returned the searched entry's groups for a UPN bind that resolved to another account, and live against a
throwaway GLAuth (`tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind/`), `mallory` bound as herself and signed
in with `boss`'s `Admins` group because `(mail=mallory)` found `boss`. `CompleteAsUser` now establishes the bound
identity and refuses any entry that is not it, with `GroupLookupFailed`:
- RFC 4532 Who-am-I (new `ILdapConnection.WhoAmI`, backed by Novell's `LdapWhoAmIOperation`). A `dn:` answer must
equal the entry's DN (`LdapDn.Equals`, `distinguishedNameMatch`). AD's `u:DOMAIN\name` answer must equal the
entry's `sAMAccountName`, which is requested automatically.
- With no Who-am-I answer (GLAuth answers protocol error 2), only a DN template is provable, and its bind DN must
equal the entry's DN. A UPN or down-level template fails closed.
- `{0}` is RFC 4514-escaped in DN templates. The validator message now leads with `EXAMPLE\{0}`.
Tests: 13 new cases in `LdapAuthServiceUserBindTests`, 11 of which fail against the pre-fix service, plus
`GLAuthUserBindIdentityTests` (live, `ZB_LDAP_USERBIND_IT=1`): the mismatch case fails pre-fix and passes after, and
the positive control passes both ways. Consumer note (README §0.4.4): a non-AD directory in `User` mode needs a DN
template that yields the entry's DN exactly as the search reports it.
### Auth-008 — SQL Server store silently truncates over-length values and matches key ids case-insensitively, diverging from SQLite
| | |
|--|--|
| Severity | Low |
| Priority | P3 |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAdminStore.cs:45` (also `:46`, `:48`; `SqlServerApiKeyStore.cs:56,77`; `SqlServerApiKeyAuditStore.cs:30-32`; `SqliteApiKeyStoreImporter.cs:258-261,292-294`; columns in `SqlServerAuthSchema.cs:79-96`) |
**Description**
Every string parameter is added with an explicit size: `NVarChar, 64` for `key_id`, `event_type` and
`remote_address`, `16` for `key_prefix`, and `256` for `display_name`. `SqlParameter` truncates an input longer
than `Size` without raising an error, so over-length values are cut down rather than rejected. The columns also use
the database's default collation, which is case-insensitive on a stock SQL Server. The SQLite store this one claims
to port "one-for-one" has `TEXT` columns, no length limits and BINARY (case-sensitive) comparison. `CreateKeyAsync`
validates only that `keyId` is non-blank and has no `_`.
I verified this live against a SQL Server 2022 container:
- A 70-char `keyId` with a 20-char prefix and a 300-char display name was created without error, and the row was
stored as 64/16/256 characters.
- `FindByKeyIdAsync` with the full 70-char id, and with a different 70-char id sharing the first 64 characters,
both found the row.
- `FindByKeyIdAsync("LINE1")` found the row stored as `line1`.
- Creating `Line1` beside `line1` failed with a PK violation.
- An 80-char audit `event_type`/`remote_address` was stored as 64 characters.
**Failure scenario**
1. **Over-length key id.** An admin mints a key with a long, descriptive key id (more than 64 characters) on a host
using `AddZbApiKeyAuthSqlServer`. Creation succeeds and the returned token embeds the full id. Verification
succeeds only because the lookup parameter is truncated the same way. The authenticated `ApiKeyIdentity.KeyId`
is the 64-character stored value, not the id the admin assigned. Any host ACL or audit correlation keyed on the
assigned id never matches.
2. **Case-variant ids in the importer.** Two SQLite keys `Line1` and `line1` (legal on SQLite) migrate through
`SqliteApiKeyStoreImporter`. The second is counted as "skipped" because the `IF NOT EXISTS` check matches
case-insensitively. After cut-over its token resolves to the other key's row, fails `SecretMismatch`, and that
client is locked out with no error at import time.
**Recommendation**
- Pass `-1`/no explicit size, or validate lengths up front and throw `ArgumentException`, in both `CreateKeyAsync`
and the importer.
- Declare `key_id` with a binary or case-sensitive collation (e.g. `COLLATE Latin1_General_100_BIN2`) to match
SQLite. Alternatively, document and enforce a case-folded, `[A-Za-z0-9-]{1,64}` key-id alphabet in
`ApiKeyAdminCommands`.
- Make the importer report key-id collisions instead of counting them as plain skips.
**Resolution**
Resolved 2026-11-09 (scadaproj#46, Auth `0.4.4`, unpublished). Re-verified live against a throwaway SQL Server 2022
container (`RTM-CU22`): `FindByKeyIdAsync("LINE1")` found the row stored as `line1`, and the importer counted
`Line1` beside `line1` as a plain skip. The offline tests reproduced the truncation: an over-length create was sent
to the server. Fixed without a schema change, so the live shared store needs no migration. A new internal helper,
`SqlServerColumnLimits`, carries the rules:
- **Lengths.** `SqlServerApiKeyAdminStore.CreateAsync` throws `ArgumentException` before connecting when `KeyId` is
over 64, `KeyPrefix` over 16 or `DisplayName` over 256 characters. Every key-id lookup and mutation (`Find*`,
`MarkUsed`, `Revoke`, `Rotate`, `SetScopes`, `SetEnabled`, `Delete`) returns not-found or `false` for an id over
64 characters without querying. Before, the truncated parameter could match a different key that shares the
first 64 characters.
- **Case.** Every `WHERE key_id = @key_id` also requires `CAST(key_id AS VARBINARY(128)) = CAST(@key_id AS VARBINARY(128))`.
The collation-aware `=` still drives the primary-key seek, and the binary compare makes the match exact, like
SQLite's `BINARY`. The column collation is deliberately not changed: that would be a primary-key migration on a
live shared store. Consequence: on a case-insensitive database, `Line1` and `line1` still cannot both exist.
`CreateAsync` already fails loudly with a PK violation in that case, and the importer now reports it.
- **Importer.** The new `init` properties `ApiKeyImportResult.KeyIdCollisions` (case-only clashes with a key
already in SQL Server) and `KeysRejected` (over-length rows) list what was not imported and why. Before, both kinds
were folded into `KeysSkipped`, or imported truncated. `KeysSkipped` now counts only exact duplicates. The
insert-if-absent statement returns a three-way outcome from inside one statement, so it stays race-safe.
- **Audit text.** `key_id`, `event_type` and `remote_address` are still truncated to 64 characters. This is now
deliberate and documented on `SqlServerApiKeyAuditStore.AppendAsync`: an audit append runs after the action it
records, so refusing it would lose the record of a change that has already happened.
Tests:
- `SqlServerColumnLimitTests` (offline, refused loopback port). Over-length create is rejected for each of the three
fields, and all 8 key-id operations answer "not found" for an over-length id. These fail pre-fix with
`SqlException`. A positive control at exactly the limits still reaches the server.
- `Live/KeyIdFidelityLiveTests` (`AUTH_SQLSERVER_CONNSTR`). Case- and trailing-space-exact lookups, an importer
case-collision report, and an importer over-length rejection. All 3 fail pre-fix.
- The full SqlServer suite passes live: 101/101.
### Auth-009 — Canonical username is the first value of a possibly multi-valued attribute, not the value the user matched
| | |
|--|--|
| Severity | Low |
| Priority | P3 |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs:437` |
**Description**
0.4.1's `ExtractUserName` returns `values[0]` of `UserNameAttribute` whenever that value is non-blank (lines
437–441). The default `UserNameAttribute` is `cn` (`LdapContracts.cs:30`), which is multi-valued in standard LDAP
schemas: OpenLDAP and 389-DS entries routinely carry both `cn: John Smith` and `cn: jsmith`. The search matched on
one of those values, but the code returns whichever value the server lists first, which need not be the one the
user typed. This is the same person and the same entry, so there is no privilege effect. However, the identity
string handed to the host changes with the 0.4.1 upgrade and depends on server attribute ordering.
**Failure scenario**
A host on the default `UserNameAttribute=cn` runs against an OpenLDAP directory where users have two `cn` values.
Before 0.4.1 `jsmith` signed in as `jsmith`. After the upgrade the result's `Username` is `John Smith`. Per-user
records, audit trails and any username-keyed role overrides in the host split into two identities, and a username
containing a space can break downstream consumers that treat it as an account name. I reproduced this through the
fake connection: typed `jsmith` returned `Username == "John Smith"`.
**Recommendation**
Prefer the attribute value that equals the typed (normalised) name under `OrdinalIgnoreCase`, which still
canonicalises case. Fall back to `values[0]` only when no value matches. Add a multi-valued-attribute test.
**Resolution**
Resolved 2026-11-09 (scadaproj#46, Auth `0.4.4`, unpublished). Re-verified through the fake connection: with
`cn: ["John Smith", "jsmith"]`, typed `jsmith` returned `Username == "John Smith"`. `ExtractUserName` now returns the
attribute value that equals the typed (normalised) name under `OrdinalIgnoreCase`. That keeps 0.4.1's case
canonicalisation, so typed `jsmith` against `JSmith` still returns `JSmith`. Only when no value matches does it fall
back to the first non-blank value, and then to the typed name, as 0.4.1 did. Both bind modes go through
`SucceedFromEntry`, so both are covered.
Tests (`LdapAuthServiceTests`):
- `Username_FromAMultiValuedAttribute_IsTheValueTheUserMatched` fails pre-fix.
- `..._StillTakesTheDirectorysCase` fails pre-fix.
- `Username_FallsBackToTheFirstValue_WhenNoValueMatchesTheTypedName` is a guard that passes both ways.
### Auth-010 — `ApiKeyVerifier` lets store exceptions escape despite its "only exception path is cancellation" contract
| | |
|--|--|
| Severity | Low |
| Priority | P3 |
| Category | Error handling & resilience |
| Status | Resolved |
| Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.ApiKeys/ApiKeyVerifier.cs:47` (contract stated at `:13`) |
**Description**
The type remark (line 13) promises "The only exception path is cancellation". Auth-002 fixed `MarkUsedAsync` to
honour that promise, but the lookup `await store.FindByKeyIdAsync(...)` at line 47 is unguarded. Its failures
propagate unchanged. With the new `SqlServerApiKeyStore` the store is a network database shared by a cluster, so
lookup failures are an expected operating condition, not a rare local-disk fault. A misconfigured
`SqlServerApiKeyOptions` also escapes: it is validated lazily in the `AuthSqlServerConnectionFactory` constructor.
With `RunMigrationsOnStartup=false` that throws `InvalidOperationException` out of the first verifier resolution or
call.
**Failure scenario**
The shared SQL Server is briefly unreachable during a failover, or the central pair loses its route to it. Every
inbound API request's `VerifyAsync` throws `Microsoft.Data.SqlClient.SqlException`; I verified this against a closed
port. Consumers wrote their middleware and interceptors against the documented contract and map
`Succeeded == false` to 401, so they do not catch this. The request surfaces as an unhandled 500, or a gRPC
`Unknown`, instead of a clean auth failure or 503, and the failure goes unaudited.
**Recommendation**
Wrap the lookup and map non-cancellation exceptions to a structured failure, for example a new
`ApiKeyFailure.StoreUnavailable`. Otherwise, correct the remark to say store exceptions propagate. Add a test
where the store throws. Optionally add `SqlServerApiKeyOptions` startup validation
(`AddOptions().Validate(...).ValidateOnStart()`), mirroring Auth-001.
**Resolution**
Resolved 2026-11-09 (scadaproj#46, Auth `0.4.4`, unpublished). Re-verified offline: an `ApiKeyVerifier` over a real
`SqlServerApiKeyStore` that dials a refused loopback port threw `Microsoft.Data.SqlClient.SqlException` out of
`VerifyAsync`. The fix takes the review's first option. `ApiKeyFailure` gains **`StoreUnavailable`**, appended last so
the existing numeric values are unchanged. `VerifyAsync` now wraps the `FindByKeyIdAsync` lookup: an
`OperationCanceledException` is rethrown, and any other exception returns `Fail(StoreUnavailable)`. The class remark
("the only exception path is cancellation") is now true, and it names the new failure.
Consumers already map `Succeeded == false` to their auth-failure path and audit it, so a store outage becomes an
audited auth failure instead of an unaudited 500 or gRPC `Unknown`. A host that wants 503 can switch on the new value.
MxAccessGateway's `CachingApiKeyVerifier` caches only successes, so a transient `StoreUnavailable` is never cached.
The optional `SqlServerApiKeyOptions` `ValidateOnStart` was **not** added. Option validation still happens in the
`AuthSqlServerConnectionFactory` constructor, so a misconfigured section still throws when the store is first
resolved, which is outside `VerifyAsync`. Validating at start could stop an existing host that registers the package
without configuring the section from booting. Left as a follow-up candidate.
Tests:
- `ApiKeyVerifierTests.VerifyAsync_StoreLookupThrows_ReturnsStoreUnavailable_InsteadOfThrowing` is a theory over a
`DataException`, an `InvalidOperationException` and an `IOException`. All three cases fail pre-fix.
- `VerifyAsync_StoreLookupCancelled_Propagates` is a guard.
- `SqlServerUnreachableVerifierTests` (offline, refused loopback port) fails pre-fix with the `SqlException`.