# Auth — current state: MxAccessGateway (`mxaccessgw`)

Repo: `~/Desktop/MxAccessGateway` (Gitea `mxaccessgw`). Stack: .NET 10 gateway (x64) + x86/net48 worker.
Auth lives entirely in the **gateway** (.NET 10); the worker never authenticates.
All paths relative to repo root; auth code under `src/ZB.MOM.WW.MxGateway.Server/`. Verified 2026-06-01.

The gateway has **two independent auth models**: gRPC API keys (programmatic clients) and
LDAP dashboard auth (web UI). They share nothing — different credentials, stores, and authz.

## Model A — gRPC API-key auth

Base: `Security/Authentication/` and `Security/Authorization/`.

- **Token format:** `Authorization: Bearer mxgw_<key-id>_<secret>`. Parsed by `ApiKeyParser.cs` (rejects malformed before any DB hit) → `ParsedApiKey(KeyId, Secret)`.
- **Hashing:** `ApiKeySecretHasher.cs` — HMAC-SHA256 with an **external pepper** (config key `MxGateway:ApiKeyPepper`, never stored beside the hash). Secrets generated by `ApiKeySecretGenerator.cs` (32 random bytes, URL-safe base64).
- **Verification:** `ApiKeyVerifier.cs` — parse → `IApiKeyStore.FindByKeyIdAsync` → reject if revoked → hash with pepper → **constant-time compare** (`CryptographicOperations.FixedTimeEquals`) → mark `LastUsedUtc`. Failures discriminated (`KeyNotFound/KeyRevoked/PepperUnavailable/SecretMismatch/MissingOrMalformedCredentials`) for audit without leaking to clients.
- **Storage:** SQLite (`AuthSqliteConnectionFactory.cs`, WAL), default `C:\ProgramData\MxGateway\gateway-auth.db`. Schema v2 (`SqliteAuthSchema.cs`): tables `api_keys` (hash, scopes JSON, constraints JSON, created/last_used/revoked), `api_key_audit` (append-only), `schema_version`. Read/write/audit split across `SqliteApiKeyStore` / `SqliteApiKeyAdminStore` / `SqliteApiKeyAuditStore`.
- **Scopes** (`GatewayScopes.cs`): `session:open/close`, `invoke:read/write/secure`, `events:read`, `metadata:read`, `admin`. Stored per key (ordinal-sorted JSON via `ApiKeyScopeSerializer.cs`).
- **Enforcement:** `Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs` + `GatewayGrpcScopeResolver.cs` — per RPC: authenticate (`Unauthenticated` on failure) → resolve required scope → check (`PermissionDenied` if missing) → push `ApiKeyIdentity` into async-local `IGatewayRequestIdentityAccessor`.
- **Constraints** (`ApiKeyConstraints.cs` + `ConstraintEnforcer.cs`): optional fine-grained limits — `ReadSubtrees/WriteSubtrees/ReadTagGlobs/WriteTagGlobs/BrowseSubtrees` (anchored case-insensitive globs), `MaxWriteClassification`, `ReadAlarmOnly`, `ReadHistorizedOnly`.
- **Admin CLI:** `apikey` subcommand (`ApiKeyAdminCliRunner.cs`, `ApiKeyAdminCommandLineParser.cs`): `init-db / create-key / list-keys / revoke-key / rotate-key / delete-key` (+ constraint flags). `delete` only works on already-revoked keys.
- **Config:** `Configuration/AuthenticationOptions.cs` → `MxGateway:Authentication:{Mode(ApiKey|Disabled), SqlitePath, PepperSecretName, RunMigrationsOnStartup}`.

## Model B — Dashboard LDAP auth (Blazor)

Base: `Dashboard/`.

- **Login:** `DashboardAuthenticator.cs` — connect (`MxGateway:Ldap`), bind service account, search user (`({UserNameAttribute}={escaped})`, RFC-escaped), re-bind as user DN to verify password, read `memberOf`. `/login` GET/POST with antiforgery (`DashboardEndpointRouteBuilderExtensions.cs`).
- **Group→role:** `MxGateway:Dashboard:GroupToRole` (case-insensitive; tries full DN then leading RDN) → roles `Admin` / `Viewer` (`DashboardRoles.cs`). Zero matched roles ⇒ login denied.
- **Cookie:** scheme `MxGateway.Dashboard`; cookie name **`MxGatewayDashboard`** (note: no `__Host-` prefix despite some docs); HttpOnly, SameSite=Strict, 8h sliding; `SecurePolicy` via `MxGateway:Dashboard:RequireHttpsCookie`. Config in `DashboardServiceCollectionExtensions.cs`.
- **SignalR hubs** (`/hubs/snapshot|alarms|events`): policy accepts the cookie OR a 30-min Data-Protection bearer minted at `GET /hubs/token` (`HubTokenService.cs`, purpose `…Dashboard.HubToken.v1`; `HubTokenAuthenticationHandler.cs` also reads `access_token` query for WS upgrades).
- **Loopback bypass:** `MxGateway:Dashboard:AllowAnonymousLocalhost` (default `true`) — `DashboardAuthorizationHandler.cs`.
- **LDAP config:** `Configuration/LdapOptions.cs` → `MxGateway:Ldap:{Enabled, Server, Port(3893), UseTls, AllowInsecureLdap, SearchBase(dc=lmxopcua,dc=local), ServiceAccountDn, ServiceAccountPassword, UserNameAttribute(cn), DisplayNameAttribute, GroupAttribute(memberOf)}`. Dev users/groups in `glauth.md` (incl. the gateway-specific `GwAdmin` group).

## Secrets & config

Pepper resolved from config (`MxGateway:ApiKeyPepper`), never stored with hashes. CLAUDE.md: API keys, passwords, `WriteSecured` payloads, `AuthenticateUser` creds never logged. Docs: `docs/Authentication.md`, `docs/Authorization.md`, `docs/GatewayDashboardDesign.md`, `docs/DesignDecisions.md`, `glauth.md`.

## Notable limits / TODOs

EventsHub has no per-session ACL yet (any dashboard user can subscribe to any session); no reconnectable sessions; single event subscriber per session; authz is scope+constraint, not per-item ACL.

---

## Adoption plan → `ZB.MOM.WW.Auth`

**Replace with the shared library:**
- **Model A is the reference implementation for `ZB.MOM.WW.Auth.ApiKeys`.** The whole `Security/Authentication/` key pipeline (parser, hasher, generator, verifier, SQLite store, audit, scope serializer, admin CLI) maps almost 1:1 onto the proposed contract — extract it here first and have ScadaBridge's Inbound API adopt the same package. Token prefix becomes configurable (`mxgw_`).
- Model B LDAP: `DashboardAuthenticator`'s bind-then-search → `ZB.MOM.WW.Auth.Ldap`; cookie/claim wiring → `ZB.MOM.WW.Auth.AspNetCore`. Migrate `MxGateway:Ldap:*` to the canonical config schema.

**Keep bespoke:**
- gRPC **scope** catalog + `GatewayGrpcAuthorizationInterceptor` + constraint globs (domain authz).
- Dashboard `Admin`/`Viewer` role meaning, hub-token specifics, loopback bypass.

**Watch:** align the API-key `MaxWriteClassification`/constraint model with ScadaBridge's per-method approval when extracting — they are different shapes of "scope a key"; the shared contract should carry constraints as an opaque, project-supplied policy rather than hard-coding either.
