# Proposed shared library: `ZB.MOM.WW.Auth`

A contract on paper — the public surface to extract so the three projects stop
re-implementing identity + API-key auth. Realizes [`../spec/SPEC.md`](../spec/SPEC.md).
**Not yet created.** Reference implementations already exist: mxaccessgw Model A (API keys),
ScadaBridge `LdapAuthService` (LDAP hygiene).

## Packages (.NET 10)

```
ZB.MOM.WW.Auth.Abstractions   # interfaces, options, result records — the stable surface
ZB.MOM.WW.Auth.Ldap           # bind-then-search authn (§2)
ZB.MOM.WW.Auth.ApiKeys        # peppered-HMAC key auth + SQLite store (§4)
ZB.MOM.WW.Auth.AspNetCore     # cookie/claim/DI helpers (§5) — OtOpcUa Admin UI, gateway, ScadaBridge
```

All four are .NET 10, which all auth-bearing processes are (OtOpcUa server, mxaccessgw
**gateway**, ScadaBridge central) — the x86/net48 mxaccessgw worker does no auth, so net48
multi-targeting is **not** required. Published to the Gitea NuGet feed; SemVer; one consumer
bump per release.

## Packaging & distribution

**Four NuGet packages, one DLL each**, on the Gitea NuGet feed, lockstep SemVer to start
(one version across all four; split to independent versions only if churn diverges). These
are **libraries** linked into each app and copied to its own `bin/` — there is **no central
auth service**. The repos stay separate processes sharing *code*, not a runtime dependency
(auth must run in-process anyway: OPC UA SDK callback, gRPC interceptor, ASP.NET middleware).
Consumers reference only what they need:

| Package (→ DLL) | Transitive deps | OtOpcUa | mxaccessgw | ScadaBridge |
|---|---|---|---|---|
| `…Auth.Abstractions` | none | ✅ | ✅ | ✅ |
| `…Auth.Ldap` | LDAP client (e.g. `System.DirectoryServices.Protocols`) | ✅ | ✅ | ✅ |
| `…Auth.ApiKeys` | `Microsoft.Data.Sqlite` | — | ✅ | ✅ |
| `…Auth.AspNetCore` | ASP.NET Core | ✅ (Admin UI) | ✅ | ✅ |

**Why OtOpcUa *does* take `.AspNetCore`:** it has two auth surfaces. Its OPC UA **data plane**
(UserName tokens via the SDK impersonation callback + ACL trie) is not HTTP and uses only
`.Ldap` + `.Abstractions` behind a bespoke `IOpcUaUserAuthenticator`. But its Blazor **Admin
UI control plane** (cookie + JWT + DataProtection + authorization policies) *is* ASP.NET Core,
so it shares the canonical claim/cookie conventions from `.AspNetCore`. Both surfaces share
`.Ldap` for the bind. (`.ApiKeys` is the only package OtOpcUa skips — it has no API-key surface.)

## `ZB.MOM.WW.Auth.Abstractions`

```csharp
public sealed record LdapOptions {            // §1 canonical schema
    public bool Enabled { get; init; } = true;
    public string Server { get; init; } = "localhost";
    public int Port { get; init; } = 3893;
    public LdapTransport Transport { get; init; } = LdapTransport.Ldaps;
    public bool AllowInsecure { get; init; }
    public string SearchBase { get; init; } = "";
    public string ServiceAccountDn { get; init; } = "";
    public string ServiceAccountPassword { get; init; } = "";
    public string UserNameAttribute { get; init; } = "cn";
    public string DisplayNameAttribute { get; init; } = "cn";
    public string GroupAttribute { get; init; } = "memberOf";
    public int ConnectionTimeoutMs { get; init; } = 10_000;
}
public enum LdapTransport { Ldaps, StartTls, None }

public sealed record LdapAuthResult(             // outcome of authn
    bool Succeeded, string Username, string DisplayName,
    IReadOnlyList<string> Groups, LdapAuthFailure? Failure);
public enum LdapAuthFailure { BadCredentials, UserNotFound, AmbiguousUser, GroupLookupFailed, ServiceAccountBindFailed, Disabled }

public interface ILdapAuthService {              // §2
    Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct);
}

public enum CanonicalRole { Viewer, Operator, Engineer, Designer, Deployer, Administrator } // ../spec/CANONICAL-ROLES.md

public interface IGroupRoleMapper<TRole> {       // §3 — TRole defaults to CanonicalRole; backing store stays per-project
    Task<GroupRoleMapping<TRole>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct);
}
public sealed record GroupRoleMapping<TRole>(IReadOnlyList<TRole> Roles, object? Scope);
// Each project expands a CanonicalRole into its native permissions/scopes at enforcement time.
```

## `ZB.MOM.WW.Auth.ApiKeys`

```csharp
public sealed record ApiKeyOptions {             // §4
    public string TokenPrefix { get; init; } = "mxgw";   // configurable per project
    public string PepperSecretName { get; init; } = "";  // resolved from secret store, never stored
    public string SqlitePath { get; init; } = "";
    public bool RunMigrationsOnStartup { get; init; } = true;
}
public interface IApiKeyVerifier {
    Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct);
}
public sealed record ApiKeyVerification(bool Succeeded, ApiKeyIdentity? Identity, ApiKeyFailure? Failure);
public enum ApiKeyFailure { MissingOrMalformed, KeyNotFound, KeyRevoked, PepperUnavailable, SecretMismatch }
public sealed record ApiKeyIdentity(string KeyId, string DisplayName, IReadOnlySet<string> Scopes, object? Constraints);

public interface IApiKeyStore {                  // default: SQLite (hash, scopes, constraints, audit)
    Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct);
    Task MarkUsedAsync(string keyId, CancellationToken ct);
}
public interface IApiKeyAdminStore { /* create / revoke / rotate / delete + audit */
    Task<bool> SetScopesAsync(string keyId, IReadOnlySet<string> scopes, CancellationToken ct);          // 0.1.3: replace scope set; secret untouched
    Task<bool> SetEnabledAsync(string keyId, bool enabled, DateTimeOffset whenUtc, CancellationToken ct); // 0.1.3: reversible enable/disable toggle; secret untouched
}
```

- Constraints are carried as an **opaque `object`** (project supplies the policy: mxaccessgw
  globs/classification, ScadaBridge per-method approval). The library does the
  parse→lookup→peppered-HMAC→constant-time-compare→audit pipeline; it does **not** interpret constraints.
- Ships the `apikey` admin verbs as a reusable command set.

### 0.1.3 admin additions

`0.1.3` adds **editable scopes** and a **reversible enable/disable toggle** with **no schema
change** (still `CurrentVersion = 2`). Both land on `IApiKeyAdminStore` and the
`ApiKeyAdminCommands` facade:

- `IApiKeyAdminStore.SetScopesAsync(keyId, scopes, ct)` — replaces a key's scope set; never
  touches the secret. Returns `false` if the key is unknown.
- `IApiKeyAdminStore.SetEnabledAsync(keyId, enabled, whenUtc, ct)` — clears (`enabled: true`) or
  sets (`enabled: false`) `revoked_utc` regardless of current state; leaves `secret_hash` and
  `last_used_utc` untouched (the distinction from rotate). Returns `false` if the key is unknown.
- `ApiKeyAdminCommands.SetScopesAsync(...)` — audited `set-scopes` verb (records scope **count**,
  not contents); returns `KeyActionResult`.
- `ApiKeyAdminCommands.SetEnabledAsync(...)` — audited `enable-key` / `disable-key` verb;
  returns `KeyActionResult`.

## `ZB.MOM.WW.Auth.AspNetCore`

- Canonical `ClaimTypes` constants (name, display, username, role, scope-id).
- Cookie defaults per §5 (HttpOnly, SameSite=Strict, configurable Secure, sliding idle).
- DI helpers: `AddZbLdapAuth(IConfiguration)`, `AddZbApiKeyAuth(IConfiguration)`.

## What stays in each consumer

OtOpcUa: `IOpcUaUserAuthenticator` adapter, ACL trie, transport security, session model.
mxaccessgw: gRPC scope catalog + interceptor, constraint globs, hub tokens.
ScadaBridge: role set + site-scoping, `ManagementActor` enforcement, JWT refresh policy.

## Open contract questions

1. **Group→role store** must support both config and DB backings without leaking either (the `object? Scope` payload covers site-scoping). Validate against ScadaBridge's union semantics.
2. **JWT/refresh**: shared helper or per-project? Only ScadaBridge has the 15-min refresh model today; OtOpcUa has cookie+JWT control plane. Decide when 2+ projects want the same shape.
3. **Constraint opacity**: confirm the `object?` boundary is enough, or whether a small `IConstraintPolicy` interface is cleaner.

See [`../GAPS.md`](../GAPS.md) for the adoption order and effort/risk.
