From 1b3928a0a273863a2ed202229a4733f00d2fc182 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 06:38:44 -0400 Subject: [PATCH] fix(security): finish the bearer-token design and cache role mappings (review 2026-10-21 05/U1, 05/P1) - ManagementAuthenticator accepts Authorization: Bearer alongside Basic on /management, the audit REST endpoints and the debug-stream hub. Tokens are validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with 30 s skew); roles and site scope inside a valid token are authoritative for its lifetime, so a bearer call costs no LDAP bind and no role re-map. - /auth/token returns expires_in + management_bearer:true, carries username, display name, roles and permitted sites, and answers 404 under DisableLogin. - CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer, re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are Basic-only; bad credentials are not retried with Basic. Debug stream uses the token via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out. - StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes; fixed the false 'fail-fast at construction' comment. Removed the dead RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime (validated > 0). - RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds, default 30 s): one refresh loads the table plus every Deployer mapping's scope rules, Invalidate() drops it (generation-guarded against in-flight refreshes). - Component-Security.md / Component-CLI.md / CLI README describe the real model (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT. --- docs/requirements/Component-CLI.md | 29 +- docs/requirements/Component-Security.md | 43 +-- .../CliTokenStore.cs | 62 ++++ .../Commands/DebugCommands.cs | 37 ++- .../ManagementHttpClient.cs | 290 +++++++++++++++++- src/ZB.MOM.WW.ScadaBridge.CLI/README.md | 17 +- .../Auth/AuthEndpoints.cs | 172 +++++++---- .../StartupValidator.cs | 11 + .../ManagementAuthenticator.cs | 70 ++++- .../JwtTokenService.cs | 185 ++++------- .../RoleMapper.cs | 54 +++- .../RoleMappingCache.cs | 129 ++++++++ .../SecurityOptions.cs | 45 ++- .../ServiceCollectionExtensions.cs | 22 +- .../CliTestAssemblySetup.cs | 22 ++ .../ManagementHttpClientTokenTests.cs | 228 ++++++++++++++ .../Auth/AuthTokenEndpointTests.cs | 109 +++++++ .../StartupValidatorTests.cs | 49 +++ .../CentralFailoverTests.cs | 41 +-- .../SecurityHardeningTests.cs | 34 +- .../ManagementAuthenticatorBearerTests.cs | 174 +++++++++++ .../CookieSessionValidatorTests.cs | 1 - .../RoleMappingCacheTests.cs | 249 +++++++++++++++ .../SecurityTests.cs | 266 +--------------- 24 files changed, 1728 insertions(+), 611 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CLI/CliTokenStore.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.Security/RoleMappingCache.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CliTestAssemblySetup.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementHttpClientTokenTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthTokenEndpointTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorBearerTests.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RoleMappingCacheTests.cs diff --git a/docs/requirements/Component-CLI.md b/docs/requirements/Component-CLI.md index 4e0dbd4c..c77ca163 100644 --- a/docs/requirements/Component-CLI.md +++ b/docs/requirements/Component-CLI.md @@ -2,7 +2,7 @@ ## Purpose -The CLI is a standalone command-line tool for scripting and automating administrative operations against the ScadaBridge central cluster. It connects to the Central Host's HTTP Management API (`POST /management`), which dispatches commands to the ManagementActor. Authentication and role resolution are handled server-side — the CLI sends credentials via HTTP Basic Auth. The CLI provides the same administrative capabilities as the Central UI, enabling automation, batch operations, and integration with CI/CD pipelines. +The CLI is a standalone command-line tool for scripting and automating administrative operations against the ScadaBridge central cluster. It connects to the Central Host's HTTP Management API (`POST /management`), which dispatches commands to the ManagementActor. Authentication and role resolution are handled server-side — the CLI exchanges its credentials once per process for a short-lived bearer token (`POST /auth/token`), falling back to HTTP Basic Auth on servers without bearer support. The CLI provides the same administrative capabilities as the Central UI, enabling automation, batch operations, and integration with CI/CD pipelines. ## Location @@ -13,31 +13,34 @@ Standalone executable, not part of the Host binary. Deployed on any machine with ## Responsibilities - Parse command-line arguments and dispatch to the appropriate management operation. -- Send HTTP requests to the Central Host's Management API endpoint with Basic Auth credentials. +- Send HTTP requests to the Central Host's Management API endpoint with a bearer token (or Basic Auth credentials on older servers). - Display structured responses from the Management API. - Support both JSON and human-readable table output formats. ## Technology - **Argument parsing**: `System.CommandLine` library for command/subcommand/option parsing with built-in help generation. -- **Transport**: HTTP client connecting to the Central Host's `POST /management` endpoint. Authentication is via HTTP Basic Auth — the server performs LDAP bind and role resolution. +- **Transport**: HTTP client connecting to the Central Host's `POST /management` endpoint. Authentication is via a bearer JWT obtained from `POST /auth/token` (or HTTP Basic Auth) — the server performs the LDAP bind and role resolution. - **Serialization**: Commands serialized as JSON with a type discriminator (`command` field). Message contracts from Commons define the command types. ## Authentication -The CLI sends user credentials to the Management API via HTTP Basic Auth: +The CLI authenticates with a bearer token exchanged once per process (review 2026-10-21 05/U1, 05/P1): -1. The user provides credentials via `--username` / `--password` options. -2. On each request, the CLI encodes credentials as a Basic Auth header and sends them with the command. -3. The server performs LDAP authentication, group lookup, and role resolution — the CLI does not communicate with LDAP directly. -4. Credentials are not stored or cached between invocations. Each CLI invocation requires fresh credentials. +1. The user provides credentials via `--username` / `--password` (or `SCADABRIDGE_USERNAME` / `SCADABRIDGE_PASSWORD`). +2. On the first management call of the process, the CLI posts the credentials to `POST /auth/token`. The server performs the LDAP bind, group lookup and role resolution, and returns a short-lived HS256 JWT carrying the username, roles and permitted sites (lifetime `JwtExpiryMinutes`, default 15 min). That is the invocation's **only** LDAP bind. +3. Every later call in the process — `POST /management`, the audit REST endpoints, and the `debug stream` hub (including its automatic reconnects) — sends `Authorization: Bearer `; the server validates it locally with no LDAP bind. A token near expiry is re-exchanged. +4. A bearer call answered `401` re-exchanges once (unless the token was minted for that very call) and, if still refused, retries with HTTP Basic and stays on Basic for the rest of the process. +5. **Basic fallback.** A server with no `/auth/token` (404/405), a login-disabled deployment (404), or an older server whose token response lacks `management_bearer: true` (its tokens are accepted nowhere) is remembered as Basic-only for the process: each call carries a Basic header and the server binds LDAP per request. A transient exchange error (5xx) uses Basic for that call only. Bad credentials (`401`) or a throttle lockout (`429`) from the exchange are reported as-is and are **not** retried with Basic (that would only add a second failed bind against the login throttle). +6. The token lives **in memory only** — never written to disk, logged or echoed — and dies with the process; each CLI invocation needs credentials. `SCADABRIDGE_AUTH_MODE=basic` disables the exchange (Basic on every call). +7. The CLI never talks to LDAP directly. ## Connection The CLI connects to the Central Host via HTTP: - **Management URL**: The URL of a central node's web server (e.g., `http://localhost:9001`). The management API is served at `POST /management` on the same host as the Central UI. -- **Failover**: For HA, use a load balancer URL in front of both central nodes. The management API is stateless (Basic Auth per request), so any central node can handle any request without sticky sessions. +- **Failover**: For HA, use a load balancer URL in front of both central nodes. The management API is stateless (a self-contained bearer token, or Basic Auth per request), and both central nodes share the JWT signing key, so any central node can handle any request without sticky sessions. - **No Akka.NET dependency**: The CLI is a pure HTTP client with no Akka.NET runtime. ## Command Structure @@ -356,7 +359,7 @@ scadabridge debug stream --id The `debug snapshot` command retrieves a point-in-time snapshot via the HTTP Management API. -The `debug stream` command streams live attribute values and alarm state changes in real-time using a SignalR WebSocket connection. The CLI connects to the `/hubs/debug-stream` SignalR hub on the central server, authenticates with Basic Auth, and subscribes to the specified instance. Events are printed as they arrive — JSON format (default) outputs one NDJSON object per event; table format shows streaming rows. Press Ctrl+C to disconnect. +The `debug stream` command streams live attribute values and alarm state changes in real-time using a SignalR WebSocket connection. The CLI connects to the `/hubs/debug-stream` SignalR hub on the central server, authenticates with the process's bearer token (the SignalR client sends it in the `Authorization` header of the negotiate request and WebSocket upgrade, and re-fetches it on every reconnect) — or Basic Auth on a server without bearer support — and subscribes to the specified instance. Events are printed as they arrive — JSON format (default) outputs one NDJSON object per event; table format shows streaming rows. Press Ctrl+C to disconnect. Key behaviors: - **Automatic reconnection**: Uses SignalR's `.WithAutomaticReconnect()` to re-establish the connection on loss. @@ -505,7 +508,7 @@ Configuration is resolved in the following priority order (highest wins): - **Connection failure**: If the CLI cannot connect to the management URL (e.g., DNS failure, connection refused), it exits with code 1 and a descriptive error message. - **Command timeout**: Ordinary CLI commands use a 30-second client deadline. All instance/artifact/site deployment aliases defer the overall deadline to the server, retaining a bounded socket-connect timeout. Server Ask expiry returns HTTP 504; a client deadline is reported locally. Bundle and Flow Debugger commands retain their explicit timeout policies. -- **Authentication failure**: If the server returns HTTP 401 (LDAP bind failed), the CLI exits with code 1. +- **Authentication failure**: If the server returns HTTP 401 (LDAP bind failed at `/auth/token` or on a Basic call, or a bearer token that a fresh exchange could not replace), the CLI exits with code 1. - **Authorization failure**: If the server returns HTTP 403, the CLI exits with code 2. ## Dependencies @@ -513,13 +516,13 @@ Configuration is resolved in the following priority order (highest wins): - **Commons**: Message contracts (`Messages/Management/`) for command type definitions and registry. - **System.CommandLine**: Command-line argument parsing. - **Microsoft.AspNetCore.SignalR.Client**: SignalR client for the `debug stream` command's WebSocket connection. -- **Management Service (#18)**: The CLI hits the central cluster via the existing HTTP Management API (`POST /management`), which dispatches to the ManagementActor. The `scadabridge audit` command group rides a parallel REST surface on the same Host (`GET /api/audit/query` and `GET /api/audit/export`), sharing HTTP Basic Auth with `/management` but bypassing the actor for read-only, keyset-paged / streaming workloads. +- **Management Service (#18)**: The CLI hits the central cluster via the existing HTTP Management API (`POST /management`), which dispatches to the ManagementActor. The `scadabridge audit` command group rides a parallel REST surface on the same Host (`GET /api/audit/query` and `GET /api/audit/export`), sharing authentication (bearer token or HTTP Basic) with `/management` but bypassing the actor for read-only, keyset-paged / streaming workloads. - **Audit Log (#23)**: The `scadabridge audit query`, `audit export`, `audit tree`, and `audit backfill-source-node` subcommands target the centralized Audit Log component's REST endpoints (`GET /api/audit/query`, `GET /api/audit/export`, `GET /api/audit/tree`, `POST /api/audit/backfill-source-node`) on the Host's Management API surface; `audit verify-chain` is a client-side no-op today (hash-chain deferred to v1.x). Permission checks (`OperationalAudit`, `AuditExport`, `Admin`) are enforced server-side by `AuditEndpoints`. ## Interactions - **Management Service (via HTTP)**: The primary runtime dependency. All operations except `debug stream` are sent as HTTP POST requests to the Management API endpoint on a central node, which dispatches to the ManagementActor. -- **Central Host**: Serves the Management API at `POST /management` and the debug stream SignalR hub at `/hubs/debug-stream`. Handles LDAP authentication, role resolution, and ManagementActor dispatch. +- **Central Host**: Serves the Management API at `POST /management` and the debug stream SignalR hub at `/hubs/debug-stream`. Issues bearer tokens at `POST /auth/token`; handles LDAP authentication, role resolution, and ManagementActor dispatch. - **Debug Stream Hub (via SignalR WebSocket)**: The `debug stream` command connects to the `/hubs/debug-stream` hub on the central server for real-time event streaming. This is the only CLI command that uses a persistent connection rather than request/response. diff --git a/docs/requirements/Component-Security.md b/docs/requirements/Component-Security.md index 12518d8a..8ad9b9b4 100644 --- a/docs/requirements/Component-Security.md +++ b/docs/requirements/Component-Security.md @@ -34,25 +34,26 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid ## Session Management -### Cookie + JWT Hybrid -- On successful authentication, the app issues a **JWT** signed with a shared symmetric key (HMAC-SHA256). Both central cluster nodes use the same signing key from configuration, so either node can issue and validate tokens. -- The JWT is embedded in an **authentication cookie** rather than being passed as a bearer token. This is the correct transport for Blazor Server, where persistent SignalR circuits do not carry Authorization headers — the browser automatically sends the cookie with every SignalR connection and HTTP request. -- The cookie is **HttpOnly** and **Secure** (requires HTTPS). -- On each request, the server extracts and validates the JWT from the cookie. All authorization decisions are made from the JWT claims without hitting the database. -- **JWT claims**: User display name, username, list of roles (`Administrator`, `Designer`, `Deployer`, `Viewer`), and for site-scoped Deployer, the list of permitted site IDs. +### Interactive sessions — cookie claims ticket +- On successful interactive login (`POST /auth/login`) the app signs the user in with ASP.NET Core **cookie authentication**. The cookie carries a data-protected **claims ticket** (no embedded JWT): display name, username, roles (`Administrator`, `Designer`, `Deployer`, `Viewer`, `Operator`, `Verifier`), the permitted site IDs of a site-scoped Deployer, the user's raw LDAP groups (`zb:group`) and the idle / role-refresh anchors. The claim shape is built by one shared `SessionClaimBuilder`. +- A cookie is the correct transport for Blazor Server: persistent SignalR circuits do not carry Authorization headers, and the browser sends the cookie with every SignalR connection and HTTP request. +- The cookie is **HttpOnly**, `SameSite=Strict`, and **Secure** (requires HTTPS) unless `RequireHttpsCookie` is explicitly turned off for a plain-HTTP dev rig. +- Both central nodes share the ASP.NET Data Protection key ring, so either node can read a cookie the other issued. -### Token Lifecycle +### CLI / API sessions — bearer JWT (`POST /auth/token`) +- `POST /auth/token` (form `username`/`password`) performs the LDAP bind (login-throttled), maps the groups to roles and site scope, and returns a short-lived **HS256 JWT** signed with `SecurityOptions.JwtSigningKey`, shared by both central nodes: `{ access_token, token_type: "Bearer", expires_in, management_bearer: true, username, display_name, roles }`. +- **JWT claims**: display name, username, roles, and for a site-scoped Deployer the permitted site IDs (no site claims = system-wide, the same normalisation the Basic path uses). Issuer and audience are both fixed to `scadabridge-central`. Lifetime is `JwtExpiryMinutes` (default **15**). +- `ManagementAuthenticator` accepts `Authorization: Bearer ` alongside HTTP Basic on every CLI-facing surface: `POST /management`, the audit REST endpoints, and the debug-stream hub (`/hubs/debug-stream`, token in the `Authorization` header of the negotiate request and WebSocket upgrade). Validation checks the HS256 signature, issuer, audience and lifetime (30 s clock skew for node-to-node drift); unsigned (`alg=none`) or other-algorithm tokens are rejected, and a failure is a plain `401 AUTH_FAILED` (no LDAP bind, so it does not feed the login throttle). The token is never logged. +- **Roles and scope inside a valid token are authoritative for its lifetime**: a bearer request performs no LDAP bind and no role re-map. A role revocation therefore reaches a bearer caller when its token expires (≤ `JwtExpiryMinutes`); rotating `JwtSigningKey` invalidates every outstanding token immediately. +- The CLI exchanges credentials once per process, keeps the token in memory only, re-exchanges once on a `401`, and falls back to HTTP Basic when the server has no bearer support — see `Component-CLI.md`. HTTP Basic stays supported on every surface. +- Under the dev/test `DisableLogin` flag `/auth/token` answers `404`: the management surfaces already authenticate every request as the dev user, and no credential-free token is minted that another deployment sharing the key might honour. +- **Signing key**: `StartupValidator` refuses to boot a Central node whose `JwtSigningKey` is missing or shorter than **32 UTF-8 bytes** (256 bits, the HMAC-SHA256 floor). `JwtTokenService` repeats the length check at construction as defence in depth. -> **Implementation note (M2.19, #15).** The interactive Central UI login path signs in -> with **bare cookie claims**, not a cookie-embedded JWT. The session lifecycle below is -> therefore enforced by the cookie middleware (`ExpireTimeSpan` + `SlidingExpiration`) plus -> a `CookieAuthenticationEvents.OnValidatePrincipal` handler — see **Session Validation -> (`OnValidatePrincipal`)** below. The embedded-JWT model remains the documented design -> intent and is the mechanism for any non-cookie bearer surface (e.g. `/auth/token`), but -> it is **not** the transport for the cookie principal. +### Cookie session lifecycle - **Idle timeout**: Configurable, default **30 minutes**. Without eligible activity within the idle window, the session is rejected and the user must re-login. Passive `/auth/ping`, activity prevalidation, and Blazor transport requests preserve the activity anchor and suppress sliding renewal; the activity endpoint renews only after authentication and antiforgery validation. Tracked via a `LastActivity` last-activity timestamp claim. The cookie's `ExpireTimeSpan` is set to the idle timeout and `SlidingExpiration` renews it on activity, so the cookie window and the explicit `OnValidatePrincipal` idle check use the **same** value and cannot contradict each other. -- **Role-mapping refresh (LDAP-free)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the stored groups — **with no LDAP call** — rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Ordinary HTTP validation uses this threshold. A live circuit refreshes mappings in fresh scopes on authentication-state reads, and mutation entrypoints require a successful current mapping before dispatch. +- **Role-mapping refresh (LDAP-free)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the stored groups — **with no LDAP call** — rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Ordinary HTTP validation uses this threshold. A live circuit refreshes mappings in fresh scopes on authentication-state reads — display reads (`GetAuthenticationStateAsync`) at most once per `CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval` (10 s) per circuit — and mutation entrypoints (`GetAuthoritativeStateAsync`) require a successful current mapping before every dispatch. +- **Role-mapping cache**: `RoleMapper` reads the LDAP-group→role table and the scope rules of every Deployer mapping through a process-wide `RoleMappingCache` snapshot (`SecurityOptions.RoleMappingCacheSeconds`, default **30**; `0` disables). One refresh loads the whole table plus all Deployer scope rules, so a cache hit costs no database round-trip — previously every Basic-authenticated management request and every circuit auth-state read issued `GetAllMappingsAsync` plus one `GetScopeRulesForMappingAsync` per matched Deployer mapping. A mapping or scope-rule change is therefore visible on every node within the TTL. `RoleMappingCache.Invalidate()` makes it immediate on the node that served the change; wiring it into the ManagementActor role-mapping / scope-rule handlers is a pending follow-up, so today the TTL alone bounds the staleness (a refresh started before an invalidation is never published over it). #### Session Validation (`OnValidatePrincipal`) - The cookie principal is built at login by a **single shared claim-builder** (`SessionClaimBuilder`). The `OnValidatePrincipal` role-refresh path rebuilds the principal through the **same** builder, so the login and refresh claim shapes cannot drift. @@ -69,13 +70,13 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid > reflected during HTTP refresh and immediately at circuit mutation checks; directory group-membership changes require re-login. ### Load Balancer Compatibility -- The authentication cookie carries a self-contained JWT — no server-side session state. A load balancer in front of the central cluster can route requests to either node without sticky sessions or a shared session store. -- Since both central nodes share the same JWT signing key, either node can validate the cookie-embedded JWT. Central failover is transparent to users with valid cookies. +- Neither the cookie nor the bearer token needs server-side session state. A load balancer in front of the central cluster can route requests to either node without sticky sessions or a shared session store. +- Both central nodes share the Data Protection key ring (cookie) and the `JwtSigningKey` (bearer token), so central failover is transparent to a valid cookie or token. ## LDAP Connection Failure - **New logins**: If the LDAP/AD server is unreachable, login attempts **fail**. Users cannot be authenticated without LDAP. -- **Active sessions**: Users with a valid (not-idle-timed-out) session can **continue operating** with their current roles during an LDAP outage. Interactive cookie sessions never re-query LDAP mid-session (the mid-session role-mapping refresh is DB-only — see *Session Validation* above), so a brief LDAP outage does not disrupt engineers mid-work; central role-mapping changes still apply within the refresh window regardless of LDAP availability. +- **Active sessions**: Users with a valid (not-idle-timed-out) session can **continue operating** with their current roles during an LDAP outage. Interactive cookie sessions never re-query LDAP mid-session (the mid-session role-mapping refresh is DB-only — see *Session Validation* above), so a brief LDAP outage does not disrupt engineers mid-work; central role-mapping changes still apply within the refresh window regardless of LDAP availability. A CLI holding a bearer token likewise keeps working until the token expires; HTTP Basic calls bind per request and fail during the outage. - **Recovery (group-membership changes)**: Because the mid-session refresh is LDAP-free, a change to a user's **directory group membership** is picked up at the user's **next login** (when LDAP is queried again), not mid-session — see the *Residual limitation* note above. ## Roles @@ -200,14 +201,14 @@ Role checks are expressed as named ASP.NET Core authorization policies (in `Auth - **Deployment Manager**: Deployer role enforcement with site scoping. - **Management Service / Central UI (secured writes)**: `RequireOperator` gates submission and `RequireVerifier` gates approval/rejection of MxGateway secured writes; the no-self-approval rule (Operator ≠ Verifier) is enforced server-side by the ManagementActor, not just in the UI. - **All central components**: Role checks are a cross-cutting concern applied at the API layer. -- **Management Service**: The ManagementActor enforces role-based authorization on every incoming command using the authenticated user identity carried in the message envelope. The CLI authenticates users via the same LDAP bind mechanism and passes the user's identity (username, roles, permitted sites) in every request message. The ManagementActor applies the same role and site-scoping rules as the Central UI — no separate authentication path exists on the server side. +- **Management Service**: The ManagementActor enforces role-based authorization on every incoming command using the authenticated user identity carried in the message envelope. `ManagementAuthenticator` resolves that identity from either a bearer JWT (issued by `/auth/token` after the same LDAP bind + role mapping) or an HTTP Basic LDAP bind, and the identity (username, roles, permitted sites) travels in every request message. The ManagementActor applies the same role and site-scoping rules as the Central UI — no separate authentication path exists on the server side. - **Transport (#24)**: Provides the `RequireDesign` policy (export) and `RequireAdmin` policy (import) enforced at both the Razor page layer and inside the `ZB.MOM.WW.ScadaBridge.Transport` service entrypoints. ## Login throttling To blunt online password-spray and brute-force against the directory, every LDAP-bind surface consults a shared in-memory throttle (`LoginThrottle`, a `ZB.MOM.WW.ScadaBridge.Security` singleton) before attempting a bind, and records the bind outcome afterwards. -- **Scope — every LDAP-bind surface**: the Central UI interactive login (`POST /auth/login`) and CLI token issue (`POST /auth/token`), plus the HTTP-Basic management/CLI surfaces fronted by `ManagementAuthenticator` — `POST /management`, the audit REST endpoints, **and the debug-stream hub (`DebugStreamHub.OnConnectedAsync` delegates its whole credential path to `ManagementAuthenticator.AuthenticateAsync`, closing the round-2 N1 gap where the hub bound LDAP directly and bypassed the throttle)**. No bind happens outside these paths. The dev/test `DisableLogin` bypass sits **above** the throttle: a login-disabled deployment never binds, so it is never throttled. +- **Scope — every LDAP-bind surface**: the Central UI interactive login (`POST /auth/login`) and CLI token issue (`POST /auth/token`), plus the HTTP-Basic branch of the management/CLI surfaces fronted by `ManagementAuthenticator` — `POST /management`, the audit REST endpoints, **and the debug-stream hub (`DebugStreamHub.OnConnectedAsync` delegates its whole credential path to `ManagementAuthenticator.AuthenticateAsync`, closing the round-2 N1 gap where the hub bound LDAP directly and bypassed the throttle)**. No bind happens outside these paths; a bearer-token request binds nothing and is not throttled. The dev/test `DisableLogin` bypass sits **above** the throttle: a login-disabled deployment never binds, so it is never throttled. - **Key and window**: counters are keyed per `{username}|{IP}` pair (lower-cased username; the IP half is the **forwarded-header-resolved client IP** when `ScadaBridge:Security:ForwardedHeaders` names a trusted proxy, otherwise the raw connection peer). A fixed window of `LoginFailureWindowMinutes` (default 5) opens on the first failure; reaching `MaxLoginFailuresPerWindow` failures (default 5) within it locks that key out for `LoginLockoutMinutes` (default 5). A successful bind clears the key; an expired window resets the count. Setting `MaxLoginFailuresPerWindow` to `0` disables throttling entirely. - **Per-IP spray ceiling (arch-review 2026-08-19 P6-9).** The per-pair key alone is blind to a *spray* — one attacker IP trying one password against many distinct usernames never reaches `MaxLoginFailuresPerWindow` on any single pair — so a secondary counter keyed on the IP alone, same window/lockout but a higher ceiling (`PerIpMaxFailures`, default 25, validated strictly greater than `MaxLoginFailuresPerWindow`), locks out *new* attempts from that IP once it is reached; a successful bind does **not** reset the per-IP counter (only window/lockout expiry does), so a spray that stumbles onto one valid account cannot ride that success to keep spraying. - **Proxy topologies and the username-lockout DoS.** The key is deliberately `{username}|{IP}`, not `{username}` alone: keying on username alone would let any network-adjacent actor lock any operator out of a SCADA control surface with five wrong passwords, repeatable indefinitely. That isolation only exists when the throttle sees real client IPs — behind a proxy **without** ForwardedHeaders trust, all clients collapse onto the proxy's IP and the DoS returns. Mitigations: (a) the shipped Traefik topologies enable trusted-proxy ForwardedHeaders (so per-IP isolation is real in the documented deployment); (b) the lockout window is short by default (`LoginLockoutMinutes` = 5) and never touches the directory account itself; (c) residual risk — an attacker spraying from their *own* IP locking out a victim username *at that IP only* — is the throttle working as designed. A success-path bypass ("a correct password unlocks") was considered and rejected: it would let an attacker who has the password bypass the lockout entirely, and it converts the throttle into a password oracle during the lockout window. @@ -216,4 +217,4 @@ To blunt online password-spray and brute-force against the directory, every LDAP ### Passive traffic and circuit refresh (2026-09-09) -`/auth/ping`, `/auth/activity`, and Blazor transport validation do not advance inactivity timestamps or trigger sliding renewal. The explicit activity endpoint validates antiforgery before renewing the cookie and recording activity by protected session ID. A bounded process-local tracker lets a live circuit observe successful activity renewal. Role refresh preserves the session ID and activity timestamp; current circuit mutation checks re-map stored groups with fail-closed lookup behavior. +`/auth/ping`, `/auth/activity`, and Blazor transport validation do not advance inactivity timestamps or trigger sliding renewal. The explicit activity endpoint validates antiforgery before renewing the cookie and recording activity by protected session ID. A bounded process-local tracker lets a live circuit observe successful activity renewal. Role refresh preserves the session ID and activity timestamp; current circuit mutation checks re-map stored groups with fail-closed lookup behavior. Display-only auth-state reads reuse the circuit's last revalidation for up to 10 s (review 2026-10-21 07/P-3), so an idle timeout or a revoked grant can show on a read-only surface for at most that long; it never reaches a mutation. diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/CliTokenStore.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/CliTokenStore.cs new file mode 100644 index 00000000..0fcc6d84 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/CliTokenStore.cs @@ -0,0 +1,62 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace ZB.MOM.WW.ScadaBridge.CLI; + +/// +/// In-memory, process-lifetime store of the bearer tokens the CLI obtains from +/// POST /auth/token (review 2026-10-21 05/U1, 05/P1). One entry per +/// (management URL, username, password) so every a +/// single CLI invocation creates shares one credential exchange — one LDAP bind per invocation +/// instead of one per management call. +/// +/// +/// Tokens are never written to disk, logged or echoed; the store dies with the process. The key +/// includes a SHA-256 digest of the password rather than the password itself, so a different +/// password for the same user never reuses a token minted for the old one. +/// +internal sealed class CliTokenStore +{ + /// The process-wide store used by the public constructor. + public static CliTokenStore Shared { get; } = new(); + + private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); + + /// Returns the (shared) session for a management URL and credential pair. + /// The management base URL. + /// The CLI username. + /// The CLI password. + /// The session carrying the cached token, if any. + public Session For(string baseUrl, string username, string password) + { + var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password))); + var key = $"{baseUrl.TrimEnd('/').ToLowerInvariant()}\n{username}\n{digest}"; + return _sessions.GetOrAdd(key, _ => new Session()); + } + + /// Token state for one credential pair against one server. + internal sealed class Session + { + /// Serialises token exchanges so concurrent calls share one bind. + public SemaphoreSlim Gate { get; } = new(1, 1); + + /// The current bearer token, or null when none is held. + public string? Token { get; set; } + + /// When should be treated as stale (expiry minus a safety margin). + public DateTimeOffset RefreshAfter { get; set; } + + /// + /// True once the server has shown it does not take bearer tokens on the management + /// surfaces (no /auth/token, an older server whose tokens nothing accepts, or a + /// login-disabled deployment): every later call goes straight to HTTP Basic. + /// + public bool BasicOnly { get; set; } + + /// True while a non-stale token is held. + /// The current instant. + /// Whether can be sent. + public bool HasUsableToken(DateTimeOffset now) => Token is not null && now < RefreshAfter; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DebugCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DebugCommands.cs index 065b8959..dec16f64 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DebugCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/DebugCommands.cs @@ -1,7 +1,6 @@ using System.CommandLine; using System.CommandLine.Parsing; using System.Net.Http.Headers; -using System.Text; using System.Text.Json; using Microsoft.AspNetCore.SignalR.Client; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; @@ -90,12 +89,44 @@ public static class DebugCommands private static async Task StreamDebugAsync(string baseUrl, string username, string password, int instanceId, string format) { var hubUrl = baseUrl.TrimEnd('/') + "/hubs/debug-stream"; - var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); + + // Exchange the credentials for a bearer token once (review 2026-10-21 05/U1, 05/P1) so + // the hub handshake — and every automatic reconnect — costs no LDAP bind. The hub + // authenticates through ManagementAuthenticator, which accepts the token in the + // Authorization header; the SignalR .NET client sends the AccessTokenProvider value + // there on negotiate and on the WebSocket upgrade, and re-invokes the provider on each + // reconnect, so a token that expired mid-stream is re-exchanged. A server without + // bearer support (or SCADABRIDGE_AUTH_MODE=basic) keeps the Basic header. + using var authClient = new ManagementHttpClient(baseUrl, username, password); + ManagementHttpClient.BearerResult bearer; + try + { + bearer = await authClient.TryGetBearerTokenAsync(CancellationToken.None); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED"); + return 1; + } + + if (bearer.Failure is not null) + { + OutputFormatter.WriteError(bearer.Failure.Error ?? "Authentication failed.", bearer.Failure.ErrorCode ?? "AUTH_FAILED"); + return 1; + } var connection = new HubConnectionBuilder() .WithUrl(hubUrl, options => { - options.Headers.Add("Authorization", $"Basic {credentials}"); + if (bearer.Token is not null) + { + options.AccessTokenProvider = async () => + (await authClient.TryGetBearerTokenAsync(CancellationToken.None)).Token; + } + else + { + options.Headers.Add("Authorization", $"Basic {authClient.BasicCredentials}"); + } }) .WithAutomaticReconnect() .Build(); diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/ManagementHttpClient.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/ManagementHttpClient.cs index 24cc3b03..0d70fa01 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/ManagementHttpClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/ManagementHttpClient.cs @@ -1,12 +1,52 @@ +using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; namespace ZB.MOM.WW.ScadaBridge.CLI; +/// +/// HTTP client for the central management surfaces (POST /management, the audit REST +/// endpoints). Authenticates with a bearer token exchanged once per process at +/// POST /auth/token, falling back to HTTP Basic when the server does not offer bearer +/// tokens (review 2026-10-21 05/U1, 05/P1). +/// +/// +/// +/// Bearer flow. The first call a process makes for a given URL + credential pair posts +/// the credentials to /auth/token (one LDAP bind) and keeps the returned JWT in memory +/// (; never persisted or logged). Every later call — from this or any +/// other client instance in the same process — sends Authorization: Bearer, which the +/// server validates without LDAP. A token nearing expiry is re-exchanged. A bearer call +/// answered 401 re-exchanges once (unless the token was minted for that very call) and, +/// if still refused, retries with Basic. +/// +/// +/// Basic fallback. A server whose /auth/token is absent (404/405), disabled +/// (login-disabled deployment, 404) or does not advertise management_bearer: true (an +/// older server, whose tokens no management surface accepts) is remembered as Basic-only for +/// the rest of the process. A transient exchange failure (5xx) uses Basic for that call only. +/// Bad credentials (401) and a throttle lockout (429) from the exchange are +/// returned as-is — retrying them with Basic would only add a second failed bind against the +/// login throttle. +/// +/// +/// SCADABRIDGE_AUTH_MODE=basic disables the exchange (Basic on every call). +/// +/// public class ManagementHttpClient : IDisposable { + /// Environment variable selecting the auth mode: token (default) or basic. + public const string AuthModeEnvVar = "SCADABRIDGE_AUTH_MODE"; + + /// Re-exchange a token this long before its stated expiry. + internal static readonly TimeSpan TokenRefreshMargin = TimeSpan.FromSeconds(60); + private readonly HttpClient _httpClient; + private readonly AuthenticationHeaderValue _basic; + private readonly string _username; + private readonly string _password; + private readonly CliTokenStore.Session? _session; /// /// Default bound on the CONNECT phase only (30 s) — how long a TCP/TLS @@ -81,10 +121,19 @@ public class ManagementHttpClient : IDisposable { Timeout = Timeout.InfiniteTimeSpan }, - baseUrl, username, password) + baseUrl, username, password, + IsBasicAuthModeForced() ? null : CliTokenStore.Shared) { } + /// + /// True when is basic, which disables the token + /// exchange for the process. + /// + /// Whether HTTP Basic is forced on every call. + internal static bool IsBasicAuthModeForced() => + string.Equals(Environment.GetEnvironmentVariable(AuthModeEnvVar)?.Trim(), "basic", StringComparison.OrdinalIgnoreCase); + /// /// Test-only constructor that accepts a pre-built (typically /// over a stub ) so the request/response handling can @@ -94,16 +143,217 @@ public class ManagementHttpClient : IDisposable /// The base URL for the management API. /// The username for HTTP Basic authentication. /// The password for HTTP Basic authentication. - internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password) + /// + /// Bearer-token store to exchange credentials through; (the default) + /// sends HTTP Basic on every call, which keeps stub-handler tests to a single request. + /// + internal ManagementHttpClient(HttpClient httpClient, string baseUrl, string username, string password, CliTokenStore? tokenStore = null) { _httpClient = httpClient; // Test seam: exposes the constructed HttpClient's effective Timeout // without requiring reflection. EffectiveTimeout = httpClient.Timeout; _httpClient.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); - var credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); - _httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Basic", credentials); + _username = username; + _password = password; + _basic = new AuthenticationHeaderValue( + "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"))); + _session = tokenStore?.For(baseUrl, username, password); + } + + /// The HTTP Basic credential value (base64 user:password), for the debug-stream hub fallback. + internal string BasicCredentials => _basic.Parameter!; + + /// Outcome of . + /// The bearer token, or when the caller should use Basic. + /// A credential/throttle failure from the exchange that must be reported, not retried with Basic. + internal readonly record struct BearerResult(string? Token, ManagementResponse? Failure); + + /// + /// Returns a usable bearer token for this client's credentials, exchanging at + /// /auth/token if none is cached — used by the debug-stream hub, which cannot replay a + /// rejected handshake. is null when the caller should fall + /// back to Basic; is set for bad credentials or a lockout. + /// + /// Cancels the exchange. + /// The token, a fall-back-to-Basic signal, or a failure to report. + internal async Task TryGetBearerTokenAsync(CancellationToken cancellationToken) + { + var auth = await AcquireAuthAsync(forceExchange: false, cancellationToken).ConfigureAwait(false); + if (auth.Failure is not null) + { + var failure = await ToManagementResponseAsync(auth.Failure, cancellationToken).ConfigureAwait(false); + auth.Failure.Dispose(); + return new BearerResult(null, failure); + } + + return new BearerResult(auth.Header.Scheme == "Bearer" ? auth.Header.Parameter : null, null); + } + + private readonly record struct AuthChoice(AuthenticationHeaderValue Header, bool FreshlyMinted, HttpResponseMessage? Failure); + + /// + /// Picks the Authorization header for the next request: the cached bearer token, a freshly + /// exchanged one, or Basic. A non-null is the exchange's own + /// 401/429 response, to be surfaced to the caller as-is. + /// + private async Task AcquireAuthAsync(bool forceExchange, CancellationToken ct) + { + var session = _session; + if (session is null || session.BasicOnly) + return new AuthChoice(_basic, false, null); + + try + { + await session.Gate.WaitAsync(ct).ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (ex is not TaskCanceledException) + { + // Callers map TaskCanceledException to their TIMEOUT result, as for the send itself. + throw new TaskCanceledException(ex.Message, ex, ct); + } + + try + { + if (session.BasicOnly) + return new AuthChoice(_basic, false, null); + if (!forceExchange && session.HasUsableToken(DateTimeOffset.UtcNow)) + return new AuthChoice(new AuthenticationHeaderValue("Bearer", session.Token), false, null); + + session.Token = null; + using var form = new FormUrlEncodedContent( + [ + new KeyValuePair("username", _username), + new KeyValuePair("password", _password), + ]); + var response = await _httpClient.PostAsync("auth/token", form, ct).ConfigureAwait(false); + + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.TooManyRequests) + return new AuthChoice(_basic, false, response); + + using (response) + { + if (response.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.MethodNotAllowed) + { + session.BasicOnly = true; + return new AuthChoice(_basic, false, null); + } + + if (!response.IsSuccessStatusCode) + return new AuthChoice(_basic, false, null); // transient: Basic for this call only + + var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (!TryParseToken(body, out var token, out var lifetime)) + { + // An older server: /auth/token exists but no management surface takes its tokens. + session.BasicOnly = true; + return new AuthChoice(_basic, false, null); + } + + session.Token = token; + var margin = lifetime > TokenRefreshMargin * 2 ? TokenRefreshMargin : TimeSpan.Zero; + session.RefreshAfter = DateTimeOffset.UtcNow + lifetime - margin; + return new AuthChoice(new AuthenticationHeaderValue("Bearer", token), true, null); + } + } + finally + { + session.Gate.Release(); + } + } + + private static bool TryParseToken(string body, out string token, out TimeSpan lifetime) + { + token = string.Empty; + lifetime = TimeSpan.FromMinutes(15); + try + { + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("management_bearer", out var bearer) || bearer.ValueKind != JsonValueKind.True + || !root.TryGetProperty("access_token", out var accessToken) || accessToken.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(accessToken.GetString())) + { + return false; + } + + token = accessToken.GetString()!; + if (root.TryGetProperty("expires_in", out var expiresIn) && expiresIn.TryGetInt32(out var seconds) && seconds > 0) + lifetime = TimeSpan.FromSeconds(seconds); + return true; + } + catch (JsonException) + { + return false; + } + } + + /// + /// Sends a request built by with the chosen credentials, + /// applying the bearer refresh-once-then-Basic policy on a 401. + /// + private async Task SendAuthenticatedAsync( + Func buildRequest, HttpCompletionOption completion, CancellationToken ct) + { + var auth = await AcquireAuthAsync(forceExchange: false, ct).ConfigureAwait(false); + if (auth.Failure is not null) + return auth.Failure; + + var response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer") + return response; + + // The server refused our bearer token. A token cached from an earlier call may simply be + // stale (expired, signing key rotated): re-exchange once and retry. + if (!auth.FreshlyMinted) + { + response.Dispose(); + auth = await AcquireAuthAsync(forceExchange: true, ct).ConfigureAwait(false); + if (auth.Failure is not null) + return auth.Failure; + response = await SendWithAsync(buildRequest, auth.Header, completion, ct).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.Unauthorized || auth.Header.Scheme != "Bearer") + return response; + } + + // A token minted moments ago is still refused (e.g. the node that answered does not share + // the signing key): fall back to Basic and stop exchanging for this process. + response.Dispose(); + response = await SendWithAsync(buildRequest, _basic, completion, ct).ConfigureAwait(false); + if (response.StatusCode != HttpStatusCode.Unauthorized && _session is not null) + _session.BasicOnly = true; + return response; + } + + private Task SendWithAsync( + Func buildRequest, AuthenticationHeaderValue header, HttpCompletionOption completion, CancellationToken ct) + { + var request = buildRequest(); + request.Headers.Authorization = header; + return _httpClient.SendAsync(request, completion, ct); + } + + private static async Task ToManagementResponseAsync(HttpResponseMessage response, CancellationToken ct) + { + var responseBody = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + return new ManagementResponse((int)response.StatusCode, responseBody, null, null); + + string? error; + string? code = null; + try + { + using var doc = JsonDocument.Parse(responseBody); + error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody; + code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null; + } + catch + { + error = responseBody; + } + + return new ManagementResponse((int)response.StatusCode, null, error, code); } /// @@ -120,12 +370,15 @@ public class ManagementHttpClient : IDisposable var body = JsonSerializer.Serialize(new { command = commandName, payload }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); - var content = new StringContent(body, Encoding.UTF8, "application/json"); - HttpResponseMessage httpResponse; try { - httpResponse = await _httpClient.PostAsync("management", content, cts.Token); + httpResponse = await SendAuthenticatedAsync( + () => new HttpRequestMessage(HttpMethod.Post, "management") + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }, + HttpCompletionOption.ResponseContentRead, cts.Token); } catch (TaskCanceledException) { @@ -165,7 +418,7 @@ public class ManagementHttpClient : IDisposable /// /api/audit/query endpoint) and returns the /// response body. Unlike , this does not wrap the call /// in the POST /management command envelope — the audit endpoints are plain - /// REST resources. Authentication (HTTP Basic) and the base address are shared. + /// REST resources. Authentication (bearer token or HTTP Basic) and the base address are shared. /// /// Path relative to the base URL, with query string. /// The request timeout. @@ -177,7 +430,9 @@ public class ManagementHttpClient : IDisposable HttpResponseMessage httpResponse; try { - httpResponse = await _httpClient.GetAsync(relativePath, cts.Token); + httpResponse = await SendAuthenticatedAsync( + () => new HttpRequestMessage(HttpMethod.Get, relativePath), + HttpCompletionOption.ResponseContentRead, cts.Token); } catch (TaskCanceledException) { @@ -216,7 +471,7 @@ public class ManagementHttpClient : IDisposable /// maintenance endpoints) with a JSON body and returns the response. Unlike /// , this does not wrap the call in the /// POST /management command envelope — these are plain REST resources. - /// Authentication (HTTP Basic) and the base address are shared. + /// Authentication (bearer token or HTTP Basic) and the base address are shared. /// /// Path relative to the base URL. /// The JSON body to send, or null for an empty body. @@ -226,12 +481,15 @@ public class ManagementHttpClient : IDisposable { using var cts = new CancellationTokenSource(timeout); - var content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json"); - HttpResponseMessage httpResponse; try { - httpResponse = await _httpClient.PostAsync(relativePath, content, cts.Token); + httpResponse = await SendAuthenticatedAsync( + () => new HttpRequestMessage(HttpMethod.Post, relativePath) + { + Content = new StringContent(body ?? "{}", Encoding.UTF8, "application/json"), + }, + HttpCompletionOption.ResponseContentRead, cts.Token); } catch (TaskCanceledException) { @@ -276,7 +534,9 @@ public class ManagementHttpClient : IDisposable /// A cancellation token that can be used to cancel the operation. /// The raw HTTP response message for streaming. public async Task SendGetStreamAsync(string relativePath, CancellationToken cancellationToken) - => await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + => await SendAuthenticatedAsync( + () => new HttpRequestMessage(HttpMethod.Get, relativePath), + HttpCompletionOption.ResponseHeadersRead, cancellationToken); /// /// Disposes the underlying HTTP client. diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 8a76c6ac..f13cbfa7 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -28,7 +28,7 @@ scadabridge --url http://central-host:5000 scadabridge --url http://localhost:9000 ``` -The management API is stateless (Basic Auth per request), so sticky sessions are unnecessary. Direct node URLs (`9001`/`9002`) are useful for diagnostics; use Traefik for active-node routing and failover. +The management API is stateless (a self-contained bearer token, or Basic Auth per request), so sticky sessions are unnecessary. Direct node URLs (`9001`/`9002`) are useful for diagnostics; use Traefik for active-node routing and failover. ## Global Options @@ -61,6 +61,7 @@ For the Docker test environment, see `docker/README.md` for a ready-to-use confi | `SCADABRIDGE_FORMAT` | Default output format (overrides config file) | | `SCADABRIDGE_USERNAME` | LDAP username (fallback when `--username` is not supplied) | | `SCADABRIDGE_PASSWORD` | LDAP password (fallback when `--password` is not supplied). Preferred over `--password` on the command line, which leaks into process listings and shell history. | +| `SCADABRIDGE_AUTH_MODE` | `token` (default) exchanges credentials for a bearer token once per process; `basic` sends HTTP Basic on every call (see *Authentication* below). | ## Output @@ -2672,13 +2673,23 @@ client deadline; explicit bundle/Flow Debugger timeouts retain their own policie ## Architecture Notes -The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It carries HTTP **Basic** credentials from `--username`/`--password`. Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`. +The CLI connects to the Central cluster over **HTTP** (`ManagementHttpClient`), posting to the `/management` endpoints at the configured `managementUrl` — normally the Traefik load balancer, which routes to the active central node. It authenticates with a bearer token (see *Authentication*). Central's endpoint handler asks the `ManagementActor` in-process via `ManagementActorHolder`. There is no Akka dependency in the CLI at all: it does not join the cluster, does not use `ClusterClient`, and does not contact a `ClusterClientReceptionist`. (Earlier revisions of this document described a ClusterClient transport that was never built.) An `HttpClient` is created per command invocation and disposed when the command completes. -Role enforcement is applied by the ManagementActor on the server side. The CLI sends `--username` / `--password` as HTTP Basic credentials. The server performs LDAP authentication, resolves group memberships, and maps them to roles such as Administrator, Designer, and Deployer. Operations require the appropriate role — for example, creating templates requires `Designer`, deploying requires `Deployer`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles. +Role enforcement is applied by the ManagementActor on the server side. The server performs LDAP authentication (once, at the token exchange — or per call on the Basic fallback), resolves group memberships, and maps them to roles such as Administrator, Designer, and Deployer. Operations require the appropriate role — for example, creating templates requires `Designer`, deploying requires `Deployer`. In the test environment, use the `multi-role` user (password: `password`) which has all three roles. + +## Authentication + +The CLI resolves `--username` / `--password` (or `SCADABRIDGE_USERNAME` / `SCADABRIDGE_PASSWORD`) and, on the first management call of the process, exchanges them at `POST /auth/token` for a short-lived bearer JWT (default 15 minutes) carrying the username, roles and permitted sites. That exchange is the invocation's only LDAP bind: every later call — `/management`, the audit REST endpoints, and `debug stream` (including its reconnects) — sends `Authorization: Bearer `, which the server validates without contacting LDAP, so a short LDAP outage does not interrupt a run that already holds a token. + +- The token is kept **in memory only** (never written to disk, logged or printed) and dies with the process. +- A call refused with `401` re-exchanges once, then retries with HTTP Basic. +- A server without bearer support — no `/auth/token`, a login-disabled deployment, or an older server whose token response lacks `management_bearer: true` — is used with HTTP Basic for the rest of the process (an LDAP bind per call, as before). +- Bad credentials or a login-throttle lockout at the exchange are reported directly (exit 1) and not retried with Basic. +- Set `SCADABRIDGE_AUTH_MODE=basic` to skip the exchange entirely. ## Issues & Missing Features diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs index a32f318e..7f6aeff1 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Auth/AuthEndpoints.cs @@ -6,9 +6,11 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.Security.Auth; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth; @@ -18,7 +20,7 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth; /// public static class AuthEndpoints { - /// Registers the /auth/login, /auth/logout, and /auth/ping endpoints on the given route builder. + /// Registers the /auth/login, /auth/token, /auth/logout, /auth/ping and /auth/activity endpoints on the given route builder. /// The route builder to add the endpoints to. /// The same instance, for call chaining. public static IEndpointRouteBuilder MapAuthEndpoints(this IEndpointRouteBuilder endpoints) @@ -118,70 +120,9 @@ public static class AuthEndpoints context.Response.Redirect("/"); }); - endpoints.MapPost("/auth/token", async (HttpContext context) => - { - var form = await context.Request.ReadFormAsync(); - var username = form["username"].ToString(); - var password = form["password"].ToString(); - - if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) - { - return Results.Json(new { error = "Username and password are required." }, statusCode: 400); - } - - // Login throttle: refuse a bind while this username+IP key is locked out - // (fixed-window password-spray guard, arch-review P1/UA5). - var throttle = context.RequestServices.GetRequiredService(); - var remoteIp = context.Connection.RemoteIpAddress?.ToString(); - if (throttle.IsLockedOut(username, remoteIp!)) - { - return Results.Json( - new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" }, - statusCode: 429); - } - - var ldapAuth = context.RequestServices.GetRequiredService(); - var jwtService = context.RequestServices.GetRequiredService(); - var roleMapper = context.RequestServices.GetRequiredService>(); - - var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted); - if (!authResult.Succeeded) - { - throttle.RecordFailure(username, remoteIp!); - return Results.Json( - new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) }, - statusCode: 401); - } - - throttle.RecordSuccess(username, remoteIp!); - - var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted); - - // Guard the opaque-Scope unwrap (review I4); see the matching note on - // /auth/login. Fall back to no site-scope rather than throwing if a future - // mapper leaves Scope null or sets a different type. - var scope = roleMapping.Scope is RoleMappingResult mapped - ? mapped - : new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false); - - var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName; - var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username; - - var token = jwtService.GenerateToken( - displayName, - resolvedUsername, - roleMapping.Roles, - scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds); - - return Results.Json(new - { - access_token = token, - token_type = "Bearer", - username = resolvedUsername, - display_name = displayName, - roles = roleMapping.Roles, - }); - }).DisableAntiforgery(); + // Credential → bearer-token exchange for the CLI (review 2026-10-21 05/U1, 05/P1). + // Antiforgery is disabled: this is a non-cookie, credential-in-body endpoint. + endpoints.MapPost("/auth/token", (Delegate)HandleTokenAsync).DisableAntiforgery(); // Logout is a state-changing authenticated action: it // keeps antiforgery validation enabled so it cannot be triggered @@ -212,6 +153,107 @@ public static class AuthEndpoints return endpoints; } + /// + /// Handler for POST /auth/token: binds the form-posted username/password + /// against LDAP (login-throttled), maps the groups to roles and site scope, and returns a + /// short-lived HS256 bearer JWT () that + /// ManagementAuthenticator accepts on POST /management, the audit REST + /// endpoints and the debug-stream hub. The token carries the username, display name, roles + /// and permitted site ids — everything those surfaces need, so a bearer request costs no + /// LDAP bind. The response's management_bearer: true marks a server whose management + /// surfaces accept the token; a client that does not see it (an older server, whose tokens + /// nothing accepted) falls back to HTTP Basic. + /// + /// + /// Under the dev/test DisableLogin flag the exchange answers 404: the + /// management surfaces already authenticate every request as the dev user, so the CLI falls + /// back to Basic, and no credential-free token is ever minted that another deployment + /// sharing the signing key might honour. + /// + /// The current HTTP context. + /// The token JSON on success; 400/401/404/429 otherwise. + public static async Task HandleTokenAsync(HttpContext context) + { + var disableLogin = context.RequestServices.GetService>()?.Value; + if (disableLogin?.DisableLogin == true) + { + return Results.Json( + new { error = "Token exchange is unavailable while login is disabled.", code = "TOKEN_EXCHANGE_DISABLED" }, + statusCode: StatusCodes.Status404NotFound); + } + + if (!context.Request.HasFormContentType) + { + return Results.Json(new { error = "Username and password are required." }, statusCode: 400); + } + + var form = await context.Request.ReadFormAsync(context.RequestAborted); + var username = form["username"].ToString(); + var password = form["password"].ToString(); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return Results.Json(new { error = "Username and password are required." }, statusCode: 400); + } + + // Login throttle: refuse a bind while this username+IP key is locked out + // (fixed-window password-spray guard, arch-review P1/UA5). + var throttle = context.RequestServices.GetRequiredService(); + var remoteIp = context.Connection.RemoteIpAddress?.ToString(); + if (throttle.IsLockedOut(username, remoteIp!)) + { + return Results.Json( + new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" }, + statusCode: 429); + } + + var ldapAuth = context.RequestServices.GetRequiredService(); + var jwtService = context.RequestServices.GetRequiredService(); + var roleMapper = context.RequestServices.GetRequiredService>(); + + var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted); + if (!authResult.Succeeded) + { + throttle.RecordFailure(username, remoteIp!); + return Results.Json( + new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, + statusCode: 401); + } + + throttle.RecordSuccess(username, remoteIp!); + + var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted); + + // Guard the opaque-Scope unwrap (review I4); see the matching note on + // /auth/login. Fall back to no site-scope rather than throwing if a future + // mapper leaves Scope null or sets a different type. + var scope = roleMapping.Scope is RoleMappingResult mapped + ? mapped + : new RoleMappingResult(roleMapping.Roles, [], IsSystemWideDeployment: false); + + var displayName = string.IsNullOrEmpty(authResult.DisplayName) ? username : authResult.DisplayName; + var resolvedUsername = string.IsNullOrEmpty(authResult.Username) ? username : authResult.Username; + + // Site ids are stamped exactly as ManagementAuthenticator's Basic path projects them: + // none for a system-wide grant, the permitted set otherwise. + var token = jwtService.GenerateToken( + displayName, + resolvedUsername, + roleMapping.Roles, + scope.IsSystemWideDeployment ? null : scope.PermittedSiteIds); + + return Results.Json(new + { + access_token = token, + token_type = "Bearer", + expires_in = (int)jwtService.TokenLifetime.TotalSeconds, + management_bearer = true, + username = resolvedUsername, + display_name = displayName, + roles = roleMapping.Roles, + }); + } + /// /// Handler for GET /auth/ping. Returns 200 while the caller's /// cookie session is still valid and 401 once it has lapsed diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs index 1ae04bc3..ce12ccf3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs @@ -85,6 +85,17 @@ public static class StartupValidator .Require("ScadaBridge:Security:JwtSigningKey", _ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["JwtSigningKey"]), "required for Central") + // The key signs the CLI bearer JWT (/auth/token → ManagementAuthenticator). + // JwtTokenService is resolved lazily (first token request), so its own + // constructor check cannot fail the boot — a 1-byte key used to boot clean + // and mint trivially forgeable tokens (review 2026-10-21 05/U1). HMAC-SHA256 + // needs >= 256 bits; measured in UTF-8 bytes, exactly as the service keys the + // HMAC. A missing key is reported by the rule above, not twice. + .Require("ScadaBridge:Security:JwtSigningKey", + value => string.IsNullOrEmpty(value) + || System.Text.Encoding.UTF8.GetByteCount(value) >= ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes, + $"must be at least {ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes} bytes " + + "(256 bits) for HMAC-SHA256 — it signs the CLI bearer tokens, and a short key makes them forgeable") // The inbound API-key pepper // backs the peppered-HMAC secret compare in the shared // ZB.MOM.WW.Auth.ApiKeys verifier (wired by AddZbApiKeyAuth at the diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs index afaa3817..56375a67 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementAuthenticator.cs @@ -10,13 +10,24 @@ using ZB.MOM.WW.ScadaBridge.Security.Auth; namespace ZB.MOM.WW.ScadaBridge.ManagementService; /// -/// Shared HTTP-Basic → LDAP authentication for the management/CLI surfaces -/// (, , and the debug-stream hub). -/// Centralising the flow here means the dev/test DisableLogin bypass is applied -/// identically on every CLI-facing surface. +/// Shared authentication for the management/CLI surfaces (, +/// , and the debug-stream hub). Accepts either +/// Authorization: Bearer <jwt> (a token minted by POST /auth/token) or +/// HTTP Basic (an LDAP bind per request). Centralising the flow here means the dev/test +/// DisableLogin bypass and both schemes are applied identically on every +/// CLI-facing surface. /// /// /// +/// Bearer (review 2026-10-21 05/U1, 05/P1): the token is validated by +/// — HS256 signature with the shared +/// JwtSigningKey, issuer, audience and lifetime (small clock skew). The username, roles +/// and permitted site ids inside a valid token are authoritative for its lifetime: a bearer +/// request performs no LDAP bind and no role re-map, mirroring "LDAP failure: active sessions +/// continue with current roles". An invalid token is a plain 401 — there is no LDAP bind to +/// throttle, so it does not feed . +/// +/// /// ScadaBridge:Security:Auth:DisableLogin swaps the cookie authentication /// scheme for AutoLoginAuthenticationHandler, which lets the interactive browser UI /// in without credentials. The CLI does not use cookies — it authenticates the @@ -61,9 +72,10 @@ public static class ManagementAuthenticator /// /// Authenticates a management/CLI request. Honours the dev/test DisableLogin bypass - /// first (); otherwise decodes HTTP Basic Auth, binds - /// against LDAP, and resolves roles. Returns a populated on - /// success, or an carrying the 401 response on any failure. + /// first (); otherwise accepts a bearer JWT (validated + /// locally, no LDAP) or decodes HTTP Basic Auth, binds against LDAP, and resolves roles. + /// Returns a populated on success, or an + /// carrying the 401 (or 429 throttle) response on any failure. /// /// The HTTP request to authenticate. /// An carrying either the user or the 401 failure result. @@ -75,12 +87,17 @@ public static class ManagementAuthenticator if (bypass is not null) return new AuthOutcome(bypass, null); - // 1. Decode Basic Auth var authHeader = context.Request.Headers.Authorization.ToString(); + + // 0. Bearer token minted by POST /auth/token — validated locally, no LDAP bind. + if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return AuthenticateBearer(context, authHeader["Bearer ".Length..].Trim()); + + // 1. Decode Basic Auth if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)) { return new AuthOutcome(null, Results.Json( - new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401)); + new { error = "Authorization header required (Basic or Bearer scheme).", code = "AUTH_FAILED" }, statusCode: 401)); } string username, password; @@ -144,4 +161,39 @@ public static class ManagementAuthenticator return new AuthOutcome(resolvedUser, null); } + + /// + /// Validates a bearer JWT and projects its claims onto an . + /// The token's roles and site scope are taken as-is (authoritative until expiry); an + /// empty site-id set is the same system-wide signal the Basic path produces. + /// + private static AuthOutcome AuthenticateBearer(HttpContext context, string token) + { + var jwt = context.RequestServices.GetService(); + var principal = jwt?.ValidateToken(token); + var username = principal?.FindFirst(JwtTokenService.UsernameClaimType)?.Value; + if (principal is null || string.IsNullOrWhiteSpace(username)) + { + return new AuthOutcome(null, Results.Json( + new { error = "Invalid or expired bearer token.", code = "AUTH_FAILED" }, statusCode: 401)); + } + + var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value; + var roles = principal.FindAll(JwtTokenService.RoleClaimType) + .Select(c => c.Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var permittedSiteIds = principal.FindAll(JwtTokenService.SiteIdClaimType) + .Select(c => c.Value) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + return new AuthOutcome( + new AuthenticatedUser( + username, + string.IsNullOrWhiteSpace(displayName) ? username : displayName, + roles, + permittedSiteIds), + null); + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs index 722d7856..e3bf19d2 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs @@ -8,6 +8,12 @@ using ZB.MOM.WW.Auth.AspNetCore; namespace ZB.MOM.WW.ScadaBridge.Security; +/// +/// Mints and validates the short-lived HMAC-SHA256 bearer JWT that POST /auth/token +/// issues and ManagementAuthenticator accepts on the management, audit REST and +/// debug-stream surfaces. Also owns the claim-type constants shared with the cookie session. +/// The Blazor cookie session does NOT embed a JWT. +/// public class JwtTokenService { private readonly SecurityOptions _options; @@ -56,16 +62,30 @@ public class JwtTokenService /// Fixed audience bound into every token and required on validation. public const string TokenAudience = "scadabridge-central"; + /// + /// Clock skew tolerated when validating exp/nbf. A bearer token minted on + /// one central node is routinely presented to the other, so a few seconds of drift + /// between the pair must not reject a valid token; the window is kept small so an + /// expired token is not honoured for long. + /// + public static readonly TimeSpan ValidationClockSkew = TimeSpan.FromSeconds(30); + + private readonly TimeProvider _timeProvider; + /// Initializes the service and validates the JWT signing key meets the minimum length requirement. - /// Security options containing the JWT signing key, expiry, and timeout settings. + /// Security options containing the JWT signing key and token lifetime. /// Logger instance. - public JwtTokenService(IOptions options, ILogger logger) + /// Clock used to stamp iat/nbf/exp; defaults to the system clock. + public JwtTokenService(IOptions options, ILogger logger, TimeProvider? timeProvider = null) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _timeProvider = timeProvider ?? TimeProvider.System; - // Fail fast: a missing or short signing key produces trivially forgeable tokens. - // HMAC-SHA256 requires a key of at least 256 bits (32 bytes). + // Defence in depth: a missing or short signing key produces trivially forgeable + // tokens. HMAC-SHA256 requires a key of at least 256 bits (32 bytes). The boot-time + // guard is StartupValidator (Central role), which fails the node before the host is + // built; this check only catches a host that bypassed it (tests, tooling). var keyByteLength = string.IsNullOrEmpty(_options.JwtSigningKey) ? 0 : Encoding.UTF8.GetByteCount(_options.JwtSigningKey); @@ -78,25 +98,26 @@ public class JwtTokenService } } + /// Lifetime of an issued bearer token (). + public TimeSpan TokenLifetime => TimeSpan.FromMinutes(_options.JwtExpiryMinutes); + /// - /// Issues a fresh JWT. sets the idle-timeout - /// anchor; when omitted (a brand-new login) it defaults to now. On a token - /// refresh the caller MUST pass the existing anchor forward so the idle window - /// continues to be measured from the user's last genuine activity rather than - /// from token issuance time. + /// Issues a short-lived bearer JWT for the management / audit / debug-stream surfaces + /// (POST /auth/token). The token carries everything ManagementAuthenticator + /// needs — username, display name, roles and the permitted site ids — and those claims + /// are authoritative until the token expires (): + /// a bearer request performs no LDAP bind and no role re-map. /// /// Human-readable display name embedded as a claim. /// Authenticated username embedded as a claim. /// Role names to embed as claims. - /// Site identifiers the user may deploy to; null for system-wide access. - /// Idle-timeout anchor; defaults to now when null (fresh login). - /// The signed JWT string, ready to embed in a cookie or bearer header. + /// Site identifiers the user may deploy to; null or empty for system-wide access. + /// The signed JWT string, ready to send as an Authorization: Bearer header. public string GenerateToken( string displayName, string username, IReadOnlyList roles, - IReadOnlyList? permittedSiteIds, - DateTimeOffset? lastActivity = null) + IReadOnlyList? permittedSiteIds) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); @@ -105,7 +126,6 @@ public class JwtTokenService { new(DisplayNameClaimType, displayName), new(UsernameClaimType, username), - new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o")) }; foreach (var role in roles) @@ -121,11 +141,13 @@ public class JwtTokenService } } + var now = _timeProvider.GetUtcNow().UtcDateTime; var token = new JwtSecurityToken( issuer: TokenIssuer, audience: TokenAudience, claims: claims, - expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes), + notBefore: now, + expires: now.Add(TokenLifetime), signingCredentials: credentials); // MapOutboundClaims=false: write the claim TYPE strings into the JWT @@ -135,19 +157,26 @@ public class JwtTokenService // MapInboundClaims=false on validation (see ValidateToken), this makes the // claim type the policy checks — RequireClaim(RoleClaimType, …) — byte-for-byte // the same string both in the token and after it is read back, with no - // mapping round-trip surprises. The "zb:" scope/display/username claims are - // not in any map and were unaffected either way; pinning both directions - // makes the role/site migration to canonical types deterministic. + // mapping round-trip surprises. var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; handler.OutboundClaimTypeMap.Clear(); return handler.WriteToken(token); } - /// Validates a JWT string and returns the decoded , or null if the token is invalid or expired. + /// + /// Validates a bearer JWT and returns the decoded , or + /// null if the token is malformed, unsigned (alg=none), signed with any + /// algorithm other than HS256, signed with a different key, expired, not yet valid, or + /// minted for a different issuer/audience. Never throws for a bad token and never logs + /// the token itself. + /// /// The JWT string to validate. /// The decoded principal, or null if validation fails. public ClaimsPrincipal? ValidateToken(string token) { + if (string.IsNullOrWhiteSpace(token)) + return null; + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey)); var validationParameters = new TokenValidationParameters { @@ -156,9 +185,14 @@ public class JwtTokenService ValidateAudience = true, ValidAudience = TokenAudience, ValidateLifetime = true, + RequireExpirationTime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = key, - ClockSkew = TimeSpan.Zero, + // Signed tokens only, and only the one algorithm we mint: an alg=none or an + // algorithm-confusion token is rejected before its claims are read. + RequireSignedTokens = true, + ValidAlgorithms = [SecurityAlgorithms.HmacSha256], + ClockSkew = ValidationClockSkew, // The token was minted carrying the canonical claim TYPE strings // verbatim (RoleClaimType = ClaimTypes.Role URI, SiteIdClaimType = @@ -181,114 +215,11 @@ public class JwtTokenService var principal = handler.ValidateToken(token, validationParameters, out _); return principal; } - catch (Exception ex) when (ex is SecurityTokenException or ArgumentException) + catch (Exception ex) when (ex is not OperationCanceledException) { - _logger.LogDebug(ex, "Token validation failed"); + // Only the exception TYPE is logged — the message can echo token segments. + _logger.LogDebug("Bearer token validation failed ({Reason})", ex.GetType().Name); return null; } } - - /// Returns true if the token carried by is within the sliding-refresh threshold and should be reissued. - /// The current authenticated principal. - /// true when the remaining token lifetime falls below . - public bool ShouldRefresh(ClaimsPrincipal principal) - { - var expClaim = principal.FindFirst("exp"); - if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix)) - return false; - - var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix); - var remaining = expiry - DateTimeOffset.UtcNow; - - return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes; - } - - /// Returns true if the session has exceeded the idle timeout based on the claim. - /// The current authenticated principal. - /// true when the elapsed time since last activity exceeds . - public bool IsIdleTimedOut(ClaimsPrincipal principal) - { - var lastActivityClaim = principal.FindFirst(LastActivityClaimType); - if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity)) - return true; - - return (DateTimeOffset.UtcNow - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes; - } - - /// - /// Issues a fresh token (new expiry, re-queried roles) while preserving the - /// existing anchor. A refresh is itself - /// triggered by a request, but it must not be treated as user activity — the - /// idle window must keep being measured from the user's last genuine interaction, - /// otherwise the documented 30-minute idle timeout could never fire for a client - /// that polls in the background. Call to advance the - /// anchor when handling a genuine user request. - /// - /// A principal that is already past the idle timeout cannot be refreshed: this - /// method returns null (the same "cannot refresh" signal it uses for missing - /// claims). Enforcing the idle check here — rather than relying on the caller to - /// invoke first — guarantees the documented 30-minute - /// idle policy holds regardless of caller discipline; otherwise an idle-expired - /// session could be kept alive indefinitely by background refreshes. - /// - /// - /// The current authenticated principal whose claims carry identity and the last-activity anchor. - /// Re-queried role list to embed in the new token. - /// Site identifiers the user may deploy to; null for system-wide access. - /// A new token string, or null if the session is idle-expired or claims are missing. - public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList currentRoles, IReadOnlyList? permittedSiteIds) - { - var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value; - var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value; - - if (displayName == null || username == null) - { - _logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims"); - return null; - } - - // An idle-expired session must not be renewed — the user must re-login. - if (IsIdleTimedOut(currentPrincipal)) - { - _logger.LogInformation( - "Cannot refresh token for {Username}: session is past the idle timeout", username); - return null; - } - - return GenerateToken(displayName, username, currentRoles, permittedSiteIds, - ReadLastActivity(currentPrincipal)); - } - - /// - /// Issues a fresh token whose anchor is - /// advanced to now. This is the explicit "user did something" path — distinct - /// from — to be called by the request pipeline when - /// handling a genuine user interaction. - /// - /// The current authenticated principal. - /// Re-queried role list to embed in the new token. - /// Site identifiers the user may deploy to; null for system-wide access. - /// A new token string with an updated last-activity anchor, or null if claims are missing. - public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList currentRoles, IReadOnlyList? permittedSiteIds) - { - var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value; - var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value; - - if (displayName == null || username == null) - { - _logger.LogWarning("Cannot record activity: missing DisplayName or Username claims"); - return null; - } - - return GenerateToken(displayName, username, currentRoles, permittedSiteIds, - DateTimeOffset.UtcNow); - } - - private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal) - { - var claim = principal.FindFirst(LastActivityClaimType); - return claim != null && DateTimeOffset.TryParse(claim.Value, out var value) - ? value - : null; - } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs index 39e5a579..45418a9a 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMapper.cs @@ -2,15 +2,31 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; namespace ZB.MOM.WW.ScadaBridge.Security; +/// +/// Maps a user's LDAP groups to ScadaBridge roles and the Deployer site scope, from the +/// DB-backed LDAP-group-mapping table. Reads through the process-wide +/// when one is supplied (production DI always does), so a +/// mapping costs no database round-trip while the snapshot is fresh. +/// public class RoleMapper { private readonly ISecurityRepository _securityRepository; + private readonly RoleMappingCache? _cache; - /// Initializes the mapper with the security repository. + /// Initializes an uncached mapper that reads the repository on every call. /// Repository used to retrieve LDAP group-to-role mappings and scope rules. public RoleMapper(ISecurityRepository securityRepository) + : this(securityRepository, null) + { + } + + /// Initializes the mapper with the security repository and the process-wide mapping cache. + /// Repository used to retrieve LDAP group-to-role mappings and scope rules. + /// Process-wide mapping snapshot; null reads the repository on every call. + public RoleMapper(ISecurityRepository securityRepository, RoleMappingCache? cache) { _securityRepository = securityRepository ?? throw new ArgumentNullException(nameof(securityRepository)); + _cache = cache; } // virtual: a test seam so HTTP-pipeline tests (e.g. the audit @@ -23,7 +39,9 @@ public class RoleMapper IReadOnlyList ldapGroups, CancellationToken ct = default) { - var allMappings = await _securityRepository.GetAllMappingsAsync(ct); + IReadOnlyList rows = _cache is not null + ? await _cache.GetAsync(_securityRepository, ct) + : await LoadMatchedRowsAsync(ldapGroups, ct); var matchedRoles = new HashSet(StringComparer.OrdinalIgnoreCase); var permittedSiteIds = new HashSet(); @@ -31,7 +49,7 @@ public class RoleMapper var hasScopedDeploymentMapping = false; var hasUnscopedDeploymentMapping = false; - foreach (var mapping in allMappings) + foreach (var mapping in rows) { // Match LDAP group names (case-insensitive) if (!ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase))) @@ -43,13 +61,12 @@ public class RoleMapper { hasDeploymentRole = true; - var scopeRules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct); - if (scopeRules.Count > 0) + if (mapping.ScopeSiteIds.Count > 0) { hasScopedDeploymentMapping = true; - foreach (var rule in scopeRules) + foreach (var siteId in mapping.ScopeSiteIds) { - permittedSiteIds.Add(rule.SiteId.ToString()); + permittedSiteIds.Add(siteId); } } else @@ -80,4 +97,27 @@ public class RoleMapper permittedSiteIds.ToList(), isSystemWide); } + + // Uncached path (no RoleMappingCache supplied): reads the table and the scope rules of the + // MATCHED Deployer mappings only, exactly as the mapper always did. + private async Task> LoadMatchedRowsAsync( + IReadOnlyList ldapGroups, CancellationToken ct) + { + var allMappings = await _securityRepository.GetAllMappingsAsync(ct); + var rows = new List(allMappings.Count); + foreach (var mapping in allMappings) + { + IReadOnlyList scope = []; + if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase) + && ldapGroups.Any(g => g.Equals(mapping.LdapGroupName, StringComparison.OrdinalIgnoreCase))) + { + var rules = await _securityRepository.GetScopeRulesForMappingAsync(mapping.Id, ct); + scope = rules.Select(r => r.SiteId.ToString()).ToArray(); + } + + rows.Add(new RoleMappingCache.MappingRow(mapping.Id, mapping.LdapGroupName, mapping.Role, scope)); + } + + return rows; + } } diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/RoleMappingCache.cs b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMappingCache.cs new file mode 100644 index 00000000..e59bed87 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Security/RoleMappingCache.cs @@ -0,0 +1,129 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; + +namespace ZB.MOM.WW.ScadaBridge.Security; + +/// +/// Process-wide, short-TTL snapshot of the LDAP-group→role mapping table and the site-scope +/// rules of every Deployer mapping (review 2026-10-21 05/P1, 07/P-3). Before it, every +/// call — one per Basic-authenticated +/// management request and one per Blazor auth-state read — issued +/// GetAllMappingsAsync plus one GetScopeRulesForMappingAsync per matched Deployer +/// mapping. The table changes only through Administrator commands, so a +/// window (default 30 s) of staleness is +/// an acceptable price: a role revocation reaches every node within that window, and the node +/// that served the change sees it at once when the mutation path calls . +/// +/// +/// +/// The snapshot holds immutable copies (never the EF entities of whichever scope loaded it), and +/// loads the scope rules of ALL Deployer mappings in one refresh, so a cache hit performs no +/// database work at all. +/// +/// +/// A refresh that started before an call is not published: a +/// generation counter is captured before the load and compared on publish, so an in-flight +/// read of the pre-change table can never overwrite the invalidation. +/// +/// +public sealed class RoleMappingCache +{ + private readonly TimeProvider _timeProvider; + private readonly IOptionsMonitor? _options; + private readonly object _lock = new(); + private Entry? _entry; + private long _generation; + + /// One cached mapping row. + /// The mapping's primary key. + /// The LDAP group the mapping matches. + /// The ScadaBridge role the group grants. + /// The permitted site ids (Deployer mappings only; empty = unscoped). + public sealed record MappingRow(int Id, string LdapGroupName, string Role, IReadOnlyList ScopeSiteIds); + + private sealed record Entry(IReadOnlyList Rows, DateTimeOffset LoadedAt); + + /// Initializes the cache. + /// Clock used to age the snapshot. + /// Security options supplying ; null uses the 30 s default. + public RoleMappingCache(TimeProvider timeProvider, IOptionsMonitor? options = null) + { + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _options = options; + } + + private TimeSpan Ttl => TimeSpan.FromSeconds(Math.Max(0, _options?.CurrentValue.RoleMappingCacheSeconds ?? 30)); + + /// + /// Discards the cached snapshot so the next mapping re-reads the database. Call after any + /// LDAP-group-mapping or site-scope-rule create/update/delete. + /// + public void Invalidate() + { + lock (_lock) + { + _generation++; + _entry = null; + } + } + + /// + /// Returns the current mapping rows, loading them through when + /// the snapshot is missing, older than the TTL, or the cache is disabled. + /// + /// The caller's (scoped) security repository, used only on a miss. + /// Cancellation token. + /// The mapping rows with their scope rules resolved. + public async Task> GetAsync(ISecurityRepository repository, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(repository); + + var ttl = Ttl; + long generation; + lock (_lock) + { + if (ttl > TimeSpan.Zero && _entry is { } cached + && _timeProvider.GetUtcNow() - cached.LoadedAt < ttl) + { + return cached.Rows; + } + + generation = _generation; + } + + var rows = await LoadAsync(repository, ct).ConfigureAwait(false); + + if (ttl > TimeSpan.Zero) + { + lock (_lock) + { + // Publish only if no Invalidate() raced the load. + if (generation == _generation) + { + _entry = new Entry(rows, _timeProvider.GetUtcNow()); + } + } + } + + return rows; + } + + private static async Task> LoadAsync(ISecurityRepository repository, CancellationToken ct) + { + var mappings = await repository.GetAllMappingsAsync(ct).ConfigureAwait(false); + var rows = new List(mappings.Count); + foreach (var mapping in mappings) + { + IReadOnlyList scope = []; + if (mapping.Role.Equals(Roles.Deployer, StringComparison.OrdinalIgnoreCase)) + { + var rules = await repository.GetScopeRulesForMappingAsync(mapping.Id, ct).ConfigureAwait(false); + scope = rules.Select(r => r.SiteId.ToString()).ToArray(); + } + + rows.Add(new MappingRow(mapping.Id, mapping.LdapGroupName, mapping.Role, scope)); + } + + return rows; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs index d77f490d..8cd81193 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs @@ -6,8 +6,10 @@ namespace ZB.MOM.WW.ScadaBridge.Security; /// /// /// JWT Bearer path (/auth/token): and -/// govern the short-lived Bearer token issued to -/// the CLI / Inbound API. They have no effect on the Blazor cookie session. +/// govern the short-lived Bearer token issued to the CLI, +/// which ManagementAuthenticator accepts on POST /management, the audit REST +/// endpoints and the debug-stream hub. They have no effect on the Blazor cookie session +/// (and none on the Inbound API, which authenticates with API keys). /// /// /// Blazor cookie session: and @@ -28,8 +30,11 @@ namespace ZB.MOM.WW.ScadaBridge.Security; public class SecurityOptions { /// - /// Symmetric HMAC-SHA256 signing key for cookie-embedded JWTs. Must be at least - /// 32 bytes (256 bits) — validated at construction. + /// Symmetric HMAC-SHA256 signing key for the bearer JWTs minted by /auth/token. + /// Must be at least UTF-8 bytes (256 bits) — enforced + /// at boot on Central nodes by the Host's StartupValidator, and again (defence in + /// depth) at construction. Both central nodes must share + /// the same key; rotating it invalidates every outstanding bearer token. /// public string JwtSigningKey { get; set; } = string.Empty; @@ -37,7 +42,11 @@ public class SecurityOptions /// Minimum signing-key length in bytes required for HMAC-SHA256 (256 bits). /// public const int MinJwtSigningKeyBytes = 32; - /// Cookie-embedded JWT lifetime in minutes before it must be refreshed. + /// + /// Lifetime, in minutes, of a bearer JWT issued by /auth/token. The roles and site + /// scope inside the token are authoritative for this long (a role revocation reaches a + /// bearer caller only when its token expires). Default: 15. Must be positive. + /// public int JwtExpiryMinutes { get; set; } = 15; /// /// Session idle timeout in minutes for the Blazor cookie session; sessions inactive @@ -55,9 +64,14 @@ public class SecurityOptions public int IdleTimeoutMinutes { get; set; } = 30; /// - /// Minutes before token expiry to trigger refresh. + /// How long, in seconds, may serve the LDAP-group→role mapping + /// table and its site-scope rules from memory before re-reading the configuration + /// database. Default: 30. 0 disables the cache (every mapping reads the + /// database). A mapping change is visible on every node within this window; the node + /// that served the change sees it immediately once + /// is called. /// - public int JwtRefreshThresholdMinutes { get; set; } = 5; + public int RoleMappingCacheSeconds { get; set; } = 30; /// /// How long a cookie session's role-mapping projection may be stale @@ -84,7 +98,7 @@ public class SecurityOptions /// /// When true (default) the authentication cookie is always marked /// Secure (sent only over HTTPS) — the correct production setting, - /// since the cookie carries the embedded JWT bearer credential. Set false + /// since the cookie is the session credential. Set false /// for an HTTP-only deployment such as the local Docker dev cluster: the /// cookie then uses SameAsRequest, so it is still Secure on /// any HTTPS request but is usable over plain HTTP. @@ -172,6 +186,21 @@ public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IVal $"enforcement is defeated."); } + // A non-positive bearer lifetime mints tokens that are already expired. + if (options.JwtExpiryMinutes <= 0) + { + return Microsoft.Extensions.Options.ValidateOptionsResult.Fail( + $"{nameof(SecurityOptions.JwtExpiryMinutes)} " + + $"({options.JwtExpiryMinutes}) must be positive."); + } + + if (options.RoleMappingCacheSeconds < 0) + { + return Microsoft.Extensions.Options.ValidateOptionsResult.Fail( + $"{nameof(SecurityOptions.RoleMappingCacheSeconds)} " + + $"({options.RoleMappingCacheSeconds}) must be non-negative (0 disables the cache)."); + } + // SECURITY: login-throttle thresholds must be non-negative. 0 for // MaxLoginFailuresPerWindow is the documented "disable throttling" escape hatch; // a negative value (or a negative window/lockout) is a misconfiguration that would diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs index d2a9f4c8..946bc6ae 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs @@ -61,6 +61,11 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // Process-wide short-TTL cache of the LDAP-group→role mapping table + scope rules + // (review 2026-10-21 05/P1). Singleton so every scoped RoleMapper shares it; the + // repository it reads through is still the caller's scoped one. + services.AddSingleton(); + // The cookie OnValidatePrincipal core. Scoped to match the // IGroupRoleMapper it depends on (which depends on the Scoped // ISecurityRepository). The clock is injected (TimeProvider) so the idle/refresh @@ -110,7 +115,10 @@ public static class ServiceCollectionExtensions // LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose // LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above) // now enforces Server + SearchBase + ServiceAccountDn + transport at startup. The - // JWT signing key continues to fail-fast at JwtTokenService construction. + // JWT signing key (presence AND the >=32-byte HMAC-SHA256 floor) is enforced + // pre-host by the Host's StartupValidator on Central nodes; JwtTokenService's + // constructor repeats the length check only as defence in depth, since it is + // resolved lazily (first /auth/token or bearer request), not at boot. // Register ASP.NET Core authentication with cookie scheme. Only the static // SecurityOptions-independent settings (login/logout paths) are set here; the @@ -176,9 +184,9 @@ public static class ServiceCollectionExtensions // renews it on activity (the middleware re-issues the cookie once past // the halfway mark of the window). An active user is therefore kept // signed in; an idle user is signed out after the idle timeout. The - // 15-minute JwtExpiryMinutes governs the lifetime of the embedded JWT - // itself (see JwtTokenService) — a separate layer from the cookie - // session window. Bound here via PostConfigure so SecurityOptions + // cookie carries a claims ticket, not a JWT — JwtExpiryMinutes governs + // only the CLI bearer token minted by /auth/token (see JwtTokenService). + // Bound here via PostConfigure so SecurityOptions // (configured by the Host after AddSecurity) is honoured. // // The cookie hardening (HttpOnly=true, SameSite=Strict, @@ -207,15 +215,15 @@ public static class ServiceCollectionExtensions // When the operator opts out of HTTPS-only cookies, // log a Warning so an HTTP-only deployment is at least audible in - // the startup log. The cookie carries the embedded JWT bearer - // credential — over plain HTTP that travels in cleartext on every + // the startup log. The cookie is the session credential — over + // plain HTTP that travels in cleartext on every // request. The default is true; this branch fires only on an // explicit opt-out (typically the dev Docker cluster). Apply sets // SecurePolicy=SameAsRequest in that case. if (!securityOptions.Value.RequireHttpsCookie) { loggerFactory.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning( - "SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The cookie-embedded JWT will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production."); + "SecurityOptions:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is SameAsRequest. The session cookie will be transmitted in cleartext over plain HTTP. This setting is intended for local dev only — set SecurityOptions:RequireHttpsCookie=true in production."); } }); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CliTestAssemblySetup.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CliTestAssemblySetup.cs new file mode 100644 index 00000000..79a702ec --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/CliTestAssemblySetup.cs @@ -0,0 +1,22 @@ +using System.Runtime.CompilerServices; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; + +/// +/// Assembly-wide test setup. The command/contract tests drive the CLI against single-request +/// fake servers (one accepted socket, or a stub handler that records "the" request), so they +/// pin the CLI to HTTP Basic: with the default token mode every process's first call would be +/// a POST /auth/token exchange those fakes do not model. The bearer exchange itself is +/// covered by , which opts in explicitly through an +/// isolated and is unaffected by this switch. +/// +internal static class CliTestAssemblySetup +{ +#pragma warning disable CA2255 // ModuleInitializer in a test assembly is intentional. + [ModuleInitializer] + internal static void ForceBasicAuthForContractTests() +#pragma warning restore CA2255 + { + Environment.SetEnvironmentVariable(ManagementHttpClient.AuthModeEnvVar, "basic"); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementHttpClientTokenTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementHttpClientTokenTests.cs new file mode 100644 index 00000000..6125f62a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CLI.Tests/ManagementHttpClientTokenTests.cs @@ -0,0 +1,228 @@ +using System.Net; +using System.Text; +using ZB.MOM.WW.ScadaBridge.CLI; + +namespace ZB.MOM.WW.ScadaBridge.CLI.Tests; + +/// +/// Bearer-token exchange in (review 2026-10-21 05/U1, 05/P1): +/// credentials are exchanged at /auth/token once per process and the token reused across +/// calls and client instances; a refused token is re-exchanged once, then Basic; a server +/// without bearer support falls back to Basic; bad credentials are not retried with Basic. +/// +public class ManagementHttpClientTokenTests +{ + private const string BaseUrl = "http://localhost:9001"; + + private sealed class FakeServer : HttpMessageHandler + { + private int _tokenCounter; + + public List<(string Path, string? Auth)> Requests { get; } = []; + public Func? TokenResponse { get; set; } + public Func? ManagementResponse { get; set; } + + public HttpResponseMessage IssueToken(int expiresIn = 900) + { + var token = $"tok-{++_tokenCounter}"; + return Json(HttpStatusCode.OK, + $"{{\"access_token\":\"{token}\",\"token_type\":\"Bearer\",\"expires_in\":{expiresIn},\"management_bearer\":true}}"); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath; + var auth = request.Headers.Authorization?.ToString(); + Requests.Add((path, auth)); + if (path == "/auth/token") + { + var form = await request.Content!.ReadAsStringAsync(cancellationToken); + Assert.Contains("username=user", form); + Assert.Null(auth); // credentials travel in the form body, never a header + return TokenResponse?.Invoke() ?? IssueToken(); + } + + return ManagementResponse?.Invoke(auth) ?? Json(HttpStatusCode.OK, "{\"ok\":true}"); + } + } + + private static HttpResponseMessage Json(HttpStatusCode status, string body) => + new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + + private static ManagementHttpClient Client(FakeServer server, CliTokenStore store) => + new(new HttpClient(server), BaseUrl, "user", "pass", store); + + private static readonly string BasicHeader = + "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("user:pass")); + + /// One exchange serves every call, across client instances in the same process. + /// A task that represents the asynchronous operation. + [Fact] + public async Task ExchangesOnce_AndReusesTokenAcrossCallsAndClients() + { + var server = new FakeServer(); + var store = new CliTokenStore(); + + using (var first = Client(server, store)) + { + Assert.Equal(200, (await first.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode); + Assert.Equal(200, (await first.SendGetAsync("api/audit/query", TimeSpan.FromSeconds(5))).StatusCode); + } + + using (var second = Client(server, store)) + { + Assert.Equal(200, (await second.SendPostAsync("api/audit/x", null, TimeSpan.FromSeconds(5))).StatusCode); + } + + Assert.Single(server.Requests, r => r.Path == "/auth/token"); + Assert.All(server.Requests.Where(r => r.Path != "/auth/token"), r => Assert.Equal("Bearer tok-1", r.Auth)); + } + + /// A cached token the server now refuses is re-exchanged once and the call retried. + /// A task that represents the asynchronous operation. + [Fact] + public async Task StaleCachedToken_IsRefreshedOnce() + { + var server = new FakeServer(); + var store = new CliTokenStore(); + using var client = Client(server, store); + await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); // mints tok-1 + + server.ManagementResponse = auth => auth == "Bearer tok-1" + ? Json(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid or expired bearer token.\",\"code\":\"AUTH_FAILED\"}") + : Json(HttpStatusCode.OK, "{\"ok\":true}"); + + var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); + + Assert.Equal(200, response.StatusCode); + Assert.Equal(2, server.Requests.Count(r => r.Path == "/auth/token")); + Assert.Equal("Bearer tok-2", server.Requests[^1].Auth); + } + + /// A token minted for this call and still refused falls back to Basic, then stays on Basic. + /// A task that represents the asynchronous operation. + [Fact] + public async Task FreshTokenRefused_FallsBackToBasic_AndStaysBasic() + { + var server = new FakeServer + { + ManagementResponse = auth => auth!.StartsWith("Bearer", StringComparison.Ordinal) + ? Json(HttpStatusCode.Unauthorized, "{\"error\":\"nope\"}") + : Json(HttpStatusCode.OK, "{\"ok\":true}"), + }; + using var client = Client(server, new CliTokenStore()); + + Assert.Equal(200, (await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode); + Assert.Equal(200, (await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5))).StatusCode); + + Assert.Single(server.Requests, r => r.Path == "/auth/token"); + Assert.Equal(BasicHeader, server.Requests[^1].Auth); + Assert.Equal(BasicHeader, server.Requests[^2].Auth); + } + + /// A server without /auth/token (404) is Basic for the rest of the process. + /// A task that represents the asynchronous operation. + [Fact] + public async Task NoTokenEndpoint_FallsBackToBasic_WithoutReprobing() + { + var server = new FakeServer { TokenResponse = () => Json(HttpStatusCode.NotFound, "") }; + using var client = Client(server, new CliTokenStore()); + + await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); + await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); + + Assert.Single(server.Requests, r => r.Path == "/auth/token"); + Assert.All(server.Requests.Where(r => r.Path == "/management"), r => Assert.Equal(BasicHeader, r.Auth)); + } + + /// An older server whose token response lacks management_bearer is treated as Basic-only. + /// A task that represents the asynchronous operation. + [Fact] + public async Task OlderServerToken_WithoutManagementBearer_UsesBasic() + { + var server = new FakeServer + { + TokenResponse = () => Json(HttpStatusCode.OK, "{\"access_token\":\"legacy\",\"token_type\":\"Bearer\"}"), + }; + using var client = Client(server, new CliTokenStore()); + + await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); + + Assert.Equal(BasicHeader, server.Requests.Single(r => r.Path == "/management").Auth); + } + + /// Bad credentials at the exchange are reported as-is, with no second (Basic) bind. + /// A task that represents the asynchronous operation. + [Fact] + public async Task BadCredentials_AreNotRetriedWithBasic() + { + var server = new FakeServer + { + TokenResponse = () => Json(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid username or password.\",\"code\":\"AUTH_FAILED\"}"), + }; + using var client = Client(server, new CliTokenStore()); + + var response = await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); + + Assert.Equal(401, response.StatusCode); + Assert.Equal("AUTH_FAILED", response.ErrorCode); + Assert.DoesNotContain(server.Requests, r => r.Path == "/management"); + } + + /// A transient exchange failure uses Basic for that call only and retries the exchange next time. + /// A task that represents the asynchronous operation. + [Fact] + public async Task TransientExchangeFailure_UsesBasicForThatCallOnly() + { + var server = new FakeServer(); + server.TokenResponse = () => Json(HttpStatusCode.ServiceUnavailable, ""); + using var client = Client(server, new CliTokenStore()); + + await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); + Assert.Equal(BasicHeader, server.Requests[^1].Auth); + + server.TokenResponse = null; // exchange recovers + await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); + + Assert.Equal(2, server.Requests.Count(r => r.Path == "/auth/token")); + Assert.Equal("Bearer tok-1", server.Requests[^1].Auth); + } + + /// The debug-stream helper returns the cached token, or a failure for bad credentials. + /// A task that represents the asynchronous operation. + [Fact] + public async Task TryGetBearerToken_ReturnsTokenOrFailure() + { + var ok = new FakeServer(); + using (var client = Client(ok, new CliTokenStore())) + { + var result = await client.TryGetBearerTokenAsync(CancellationToken.None); + Assert.Equal("tok-1", result.Token); + Assert.Null(result.Failure); + } + + var bad = new FakeServer + { + TokenResponse = () => Json(HttpStatusCode.Unauthorized, "{\"error\":\"Invalid username or password.\",\"code\":\"AUTH_FAILED\"}"), + }; + using (var client = Client(bad, new CliTokenStore())) + { + var result = await client.TryGetBearerTokenAsync(CancellationToken.None); + Assert.Null(result.Token); + Assert.Equal(401, result.Failure!.StatusCode); + } + } + + /// The internal test constructor without a store never exchanges (Basic only). + /// A task that represents the asynchronous operation. + [Fact] + public async Task NoStore_SendsBasicOnly() + { + var server = new FakeServer(); + using var client = new ManagementHttpClient(new HttpClient(server), BaseUrl, "user", "pass"); + + await client.SendCommandAsync("ListSites", new { }, TimeSpan.FromSeconds(5)); + + Assert.Equal(new[] { ("/management", (string?)BasicHeader) }, server.Requests); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthTokenEndpointTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthTokenEndpointTests.cs new file mode 100644 index 00000000..77e864e2 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/AuthTokenEndpointTests.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ZB.MOM.WW.Auth.Abstractions.Ldap; +using ZB.MOM.WW.Auth.Abstractions.Roles; +using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.Security.Auth; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; + +/// +/// POST /auth/token (review 2026-10-21 05/U1): the minted token carries everything +/// ManagementAuthenticator needs (username, roles, site scope), the response advertises +/// management_bearer + expires_in, and a login-disabled deployment refuses the exchange. +/// +public class AuthTokenEndpointTests +{ + private const string Key = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough"; + + private static HttpContext Context(bool disableLogin, RoleMappingResult mapping, out JwtTokenService jwt) + { + var ldap = Substitute.For(); + ldap.AuthenticateAsync("alice", "pw", Arg.Any()) + .Returns(LdapAuthResult.Success("alice", "Alice A", ["SiteDeployers"])); + var mapper = Substitute.For>(); + mapper.MapAsync(Arg.Any>(), Arg.Any()) + .Returns(new GroupRoleMapping(mapping.Roles, Scope: mapping)); + var options = Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = 15 }); + jwt = new JwtTokenService(options, NullLogger.Instance); + + var services = new ServiceCollection().AddLogging(); + services.AddSingleton(Options.Create(new AuthDisableLoginOptions { DisableLogin = disableLogin })); + services.AddSingleton>(options); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + services.AddSingleton(ldap); + services.AddSingleton(mapper); + services.AddSingleton(jwt); + var context = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; + context.Request.Method = "POST"; + context.Request.ContentType = "application/x-www-form-urlencoded"; + context.Request.Form = new FormCollection(new() + { + ["username"] = "alice", + ["password"] = "pw", + }); + context.Response.Body = new MemoryStream(); + return context; + } + + private static async Task<(int Status, JsonElement Body)> Execute(HttpContext context) + { + var result = await AuthEndpoints.HandleTokenAsync(context); + await result.ExecuteAsync(context); + context.Response.Body.Position = 0; + using var doc = await JsonDocument.ParseAsync(context.Response.Body); + return (context.Response.StatusCode, doc.RootElement.Clone()); + } + + /// The token round-trips the user's roles and site scope and advertises bearer support. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Token_CarriesRolesAndScope_AndAdvertisesBearer() + { + var context = Context(false, new RoleMappingResult([Roles.Deployer], ["4", "9"], false), out var jwt); + + var (status, body) = await Execute(context); + + Assert.Equal(200, status); + Assert.True(body.GetProperty("management_bearer").GetBoolean()); + Assert.Equal(15 * 60, body.GetProperty("expires_in").GetInt32()); + var principal = jwt.ValidateToken(body.GetProperty("access_token").GetString()!); + Assert.NotNull(principal); + Assert.Equal("alice", principal!.FindFirst(JwtTokenService.UsernameClaimType)!.Value); + Assert.Equal("Alice A", principal.FindFirst(JwtTokenService.DisplayNameClaimType)!.Value); + Assert.Equal([Roles.Deployer], principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value)); + Assert.Equal(["4", "9"], principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value)); + } + + /// A system-wide grant carries no site-id claims (empty == system-wide downstream). + /// A task that represents the asynchronous operation. + [Fact] + public async Task Token_SystemWide_HasNoSiteClaims() + { + var context = Context(false, new RoleMappingResult([Roles.Deployer], [], true), out var jwt); + + var (_, body) = await Execute(context); + + var principal = jwt.ValidateToken(body.GetProperty("access_token").GetString()!); + Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType)); + } + + /// A login-disabled deployment refuses the exchange so the CLI falls back to Basic. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Token_WhenLoginDisabled_Is404() + { + var context = Context(true, new RoleMappingResult([Roles.Deployer], [], true), out _); + + var (status, body) = await Execute(context); + + Assert.Equal(404, status); + Assert.False(body.TryGetProperty("access_token", out _)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs index d6b2ca71..c3cf03d0 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs @@ -330,6 +330,55 @@ public class StartupValidatorTests Assert.Contains("JwtSigningKey required for Central", ex.Message); } + /// + /// A present-but-short JWT signing key fails the boot (review 2026-10-21 05/U1): it signs the + /// CLI bearer tokens, and JwtTokenService is only resolved on the first token request. + /// + [Fact] + public void Central_ShortJwtSigningKey_FailsValidation() + { + var values = ValidCentralConfig(); + values["ScadaBridge:Security:JwtSigningKey"] = new string('k', 31); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.Contains("JwtSigningKey must be at least 32 bytes", ex.Message); + } + + /// The key floor is measured in UTF-8 bytes, so a 32-byte key passes. + [Fact] + public void Central_ThirtyTwoByteJwtSigningKey_Passes() + { + var values = ValidCentralConfig(); + values["ScadaBridge:Security:JwtSigningKey"] = new string('k', 32); + var config = BuildConfig(values); + + Assert.Null(Record.Exception(() => StartupValidator.Validate(config))); + } + + /// A missing key reports only the "required" rule, not also the length rule. + [Fact] + public void Central_MissingJwtSigningKey_DoesNotAlsoReportLength() + { + var values = ValidCentralConfig(); + values.Remove("ScadaBridge:Security:JwtSigningKey"); + var config = BuildConfig(values); + + var ex = Assert.Throws(() => StartupValidator.Validate(config)); + Assert.DoesNotContain("at least 32 bytes", ex.Message); + } + + /// Site nodes carry no signing key and are not subject to the floor. + [Fact] + public void Site_ShortJwtSigningKey_IsIgnored() + { + var values = ValidSiteConfig(); + values["ScadaBridge:Security:JwtSigningKey"] = "x"; + var config = BuildConfig(values); + + Assert.Null(Record.Exception(() => StartupValidator.Validate(config))); + } + /// Verifies that a central node missing API key pepper fails validation. /// The result. [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs index 0fd199f9..5a3d4236 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/CentralFailoverTests.cs @@ -18,8 +18,7 @@ public class CentralFailoverTests { JwtSigningKey = signingKey, JwtExpiryMinutes = 15, - IdleTimeoutMinutes = 30, - JwtRefreshThresholdMinutes = 5 + IdleTimeoutMinutes = 30 }); return new JwtTokenService(options, NullLogger.Instance); } @@ -124,44 +123,6 @@ public class CentralFailoverTests Assert.NotNull(expClaim); } - /// Verifies that an idle timeout is detected. - [Fact] - public void JwtToken_IdleTimeout_Detected() - { - var jwtService = CreateJwtService(); - - var token = jwtService.GenerateToken( - displayName: "Idle User", - username: "idle_user", - roles: new[] { "Viewer" }, - permittedSiteIds: null); - - var principal = jwtService.ValidateToken(token); - Assert.NotNull(principal); - - // Token was just generated — should NOT be idle timed out - Assert.False(jwtService.IsIdleTimedOut(principal!)); - } - - /// Verifies that the refresh check detects a token near expiry. - [Fact] - public void JwtToken_ShouldRefresh_DetectsNearExpiry() - { - var jwtService = CreateJwtService(); - - var token = jwtService.GenerateToken( - displayName: "User", - username: "user", - roles: new[] { "Administrator" }, - permittedSiteIds: null); - - var principal = jwtService.ValidateToken(token); - Assert.NotNull(principal); - - // Token was just generated with 15min expiry and 5min threshold — NOT near expiry - Assert.False(jwtService.ShouldRefresh(principal!)); - } - /// Verifies that deployment status for multiple instances is tracked independently. [Trait("Category", "Integration")] [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs index 48a214d9..7a302370 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/SecurityHardeningTests.cs @@ -17,8 +17,7 @@ public class SecurityHardeningTests { JwtSigningKey = signingKey, JwtExpiryMinutes = 15, - IdleTimeoutMinutes = 30, - JwtRefreshThresholdMinutes = 5 + IdleTimeoutMinutes = 30 }); return new JwtTokenService(options, NullLogger.Instance); } @@ -118,7 +117,6 @@ public class SecurityHardeningTests Assert.Equal(15, options.JwtExpiryMinutes); Assert.Equal(30, options.IdleTimeoutMinutes); - Assert.Equal(5, options.JwtRefreshThresholdMinutes); } /// Verifies the case: jwt Token, tampered Payload, fails Validation. @@ -151,36 +149,6 @@ public class SecurityHardeningTests Assert.Null(principal); } - /// Verifies the case: jwt Refresh Token, preserves Identity. - [Fact] - public void JwtRefreshToken_PreservesIdentity() - { - var jwtService = CreateJwtService(); - - var originalToken = jwtService.GenerateToken( - displayName: "Original User", - username: "orig_user", - roles: new[] { "Administrator", "Designer" }, - permittedSiteIds: new[] { "site-1" }); - - var principal = jwtService.ValidateToken(originalToken); - Assert.NotNull(principal); - - // Refresh the token - var refreshedToken = jwtService.RefreshToken( - principal!, - new[] { "Administrator", "Designer" }, - new[] { "site-1" }); - - Assert.NotNull(refreshedToken); - - var refreshedPrincipal = jwtService.ValidateToken(refreshedToken!); - Assert.NotNull(refreshedPrincipal); - - Assert.Equal("Original User", refreshedPrincipal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value); - Assert.Equal("orig_user", refreshedPrincipal.FindFirst(JwtTokenService.UsernameClaimType)?.Value); - } - /// Verifies the case: startup Validator, rejects Insecure Ldap In Production. [Fact] public void StartupValidator_RejectsInsecureLdapInProduction() diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorBearerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorBearerTests.cs new file mode 100644 index 00000000..6b41c675 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorBearerTests.cs @@ -0,0 +1,174 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ZB.MOM.WW.Auth.Abstractions.Ldap; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.Security.Auth; + +namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; + +/// +/// Bearer-token branch of (review 2026-10-21 05/U1, 05/P1): +/// a JWT minted by /auth/token authenticates the management/audit/debug surfaces without +/// an LDAP bind, and every malformed, expired, tampered, foreign-key or unsigned token is a 401. +/// +public class ManagementAuthenticatorBearerTests +{ + private const string Key = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough"; + + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private static JwtTokenService Jwt(string key = Key, TimeProvider? clock = null) => + new(Options.Create(new SecurityOptions { JwtSigningKey = key, JwtExpiryMinutes = 15 }), + NullLogger.Instance, clock); + + private static (HttpContext Context, ILdapAuthService Ldap) ContextWithAuthorization(string authorization) + { + var ldap = Substitute.For(); + var services = new ServiceCollection(); + services.AddSingleton>( + Options.Create(new AuthDisableLoginOptions { DisableLogin = false })); + services.AddSingleton>( + Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = 15 })); + services.AddLogging(); + services.AddSingleton(ldap); + services.AddScoped(); + var context = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; + context.Request.Headers.Authorization = authorization; + return (context, ldap); + } + + /// A valid token yields its username, roles and site scope with no LDAP bind. + /// A task that represents the asynchronous operation. + [Fact] + public async Task ValidBearer_AuthenticatesFromClaims_WithoutLdap() + { + var token = Jwt().GenerateToken("Dee Ployer", "deployer", [Roles.Deployer, Roles.Viewer], ["3", "7"]); + var (context, ldap) = ContextWithAuthorization($"Bearer {token}"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.Failure); + Assert.NotNull(outcome.User); + Assert.Equal("deployer", outcome.User!.Username); + Assert.Equal("Dee Ployer", outcome.User.DisplayName); + Assert.Equal(new[] { Roles.Deployer, Roles.Viewer }, outcome.User.Roles); + Assert.Equal(new[] { "3", "7" }, outcome.User.PermittedSiteIds); + await ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default); + } + + /// A token minted without site ids is system-wide (empty permitted set), like the Basic path. + /// A task that represents the asynchronous operation. + [Fact] + public async Task ValidBearer_NoSiteClaims_IsSystemWide() + { + var token = Jwt().GenerateToken("Admin", "admin", [Roles.Administrator], null); + var (context, _) = ContextWithAuthorization($"Bearer {token}"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.NotNull(outcome.User); + Assert.Empty(outcome.User!.PermittedSiteIds); + } + + /// An expired token (past the 30 s skew) is rejected. + /// A task that represents the asynchronous operation. + [Fact] + public async Task ExpiredBearer_Is401() + { + var issuedLongAgo = Jwt(clock: new FixedClock(DateTimeOffset.UtcNow.AddMinutes(-20))); + var token = issuedLongAgo.GenerateToken("U", "u", [Roles.Administrator], null); + var (context, _) = ContextWithAuthorization($"Bearer {token}"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.User); + Assert.NotNull(outcome.Failure); + } + + /// A token whose payload was edited (privilege escalation attempt) fails the signature check. + /// A task that represents the asynchronous operation. + [Fact] + public async Task TamperedBearer_Is401() + { + var token = Jwt().GenerateToken("V", "viewer", [Roles.Viewer], null); + var parts = token.Split('.'); + var payload = Encoding.UTF8.GetString(Base64UrlDecode(parts[1])) + .Replace(Roles.Viewer, Roles.Administrator, StringComparison.Ordinal); + var tampered = $"{parts[0]}.{Base64UrlEncode(Encoding.UTF8.GetBytes(payload))}.{parts[2]}"; + var (context, _) = ContextWithAuthorization($"Bearer {tampered}"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.User); + Assert.NotNull(outcome.Failure); + } + + /// A token signed with a different key (another environment) is rejected. + /// A task that represents the asynchronous operation. + [Fact] + public async Task WrongKeyBearer_Is401() + { + var token = Jwt("a-completely-different-signing-key-for-hmac-sha256-validation") + .GenerateToken("A", "admin", [Roles.Administrator], null); + var (context, _) = ContextWithAuthorization($"Bearer {token}"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.User); + Assert.NotNull(outcome.Failure); + } + + /// An unsigned alg=none token is rejected. + /// A task that represents the asynchronous operation. + [Fact] + public async Task UnsignedAlgNoneBearer_Is401() + { + var unsigned = new JwtSecurityToken( + issuer: JwtTokenService.TokenIssuer, + audience: JwtTokenService.TokenAudience, + claims: [new Claim(JwtTokenService.UsernameClaimType, "admin"), + new Claim(JwtTokenService.RoleClaimType, Roles.Administrator)], + expires: DateTime.UtcNow.AddMinutes(10)); + var handler = new JwtSecurityTokenHandler(); + handler.OutboundClaimTypeMap.Clear(); + var token = handler.WriteToken(unsigned); // header {"alg":"none"}, empty signature + var (context, _) = ContextWithAuthorization($"Bearer {token}"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.User); + Assert.NotNull(outcome.Failure); + } + + /// Garbage after Bearer is a 401, not an exception. + /// A task that represents the asynchronous operation. + [Fact] + public async Task GarbageBearer_Is401() + { + var (context, _) = ContextWithAuthorization("Bearer not-a-jwt"); + + var outcome = await ManagementAuthenticator.AuthenticateAsync(context); + + Assert.Null(outcome.User); + Assert.NotNull(outcome.Failure); + } + + private static byte[] Base64UrlDecode(string s) + { + var padded = s.Replace('-', '+').Replace('_', '/'); + padded = padded.PadRight(padded.Length + (4 - padded.Length % 4) % 4, '='); + return Convert.FromBase64String(padded); + } + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs index 07a24a0b..cacd78cc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/CookieSessionValidatorTests.cs @@ -82,7 +82,6 @@ public class CookieSessionValidatorTests JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, IdleTimeoutMinutes = 30, - JwtRefreshThresholdMinutes = 5, RoleRefreshThresholdMinutes = 15, }; diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RoleMappingCacheTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RoleMappingCacheTests.cs new file mode 100644 index 00000000..9de52c06 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RoleMappingCacheTests.cs @@ -0,0 +1,249 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.Security.Tests; + +/// +/// + (review 2026-10-21 05/P1, 07/P-3): +/// a fresh snapshot serves mappings with no repository reads, it expires after +/// , +/// forces a re-read, and the scope rules are resolved once per refresh. +/// +public class RoleMappingCacheTests +{ + private sealed class MutableClock : TimeProvider + { + public DateTimeOffset Now { get; set; } = new(2026, 10, 22, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => Now; + } + + // Counts the two reads RoleMapper uses; every other member is unused here. + private sealed class CountingRepository : ISecurityRepository + { + public List Mappings { get; } = []; + public Dictionary> ScopeRules { get; } = []; + public int MappingReads { get; private set; } + public int ScopeReads { get; private set; } + + /// + public Task> GetAllMappingsAsync(CancellationToken cancellationToken = default) + { + MappingReads++; + return Task.FromResult>(Mappings.ToList()); + } + + /// + public Task> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default) + { + ScopeReads++; + return Task.FromResult(ScopeRules.TryGetValue(ldapGroupMappingId, out var rules) + ? rules + : (IReadOnlyList)Array.Empty()); + } + + /// + public Task GetMappingByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// + public Task> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// + public Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// + public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// + public Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// + public Task GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// + public Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// + public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// + public Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } + + private sealed class StaticMonitor(SecurityOptions value) : IOptionsMonitor + { + public SecurityOptions CurrentValue => value; + + public SecurityOptions Get(string? name) => value; + + public IDisposable? OnChange(Action listener) => null; + } + + private static LdapGroupMapping Mapping(int id, string group, string role) => new(group, role) { Id = id }; + + private static (CountingRepository Repo, MutableClock Clock, RoleMappingCache Cache, RoleMapper Mapper) Create(int ttlSeconds = 30) + { + var repo = new CountingRepository(); + repo.Mappings.Add(Mapping(1, "Admins", Roles.Administrator)); + repo.Mappings.Add(Mapping(2, "SiteDeployers", Roles.Deployer)); + repo.ScopeRules[2] = [new SiteScopeRule { LdapGroupMappingId = 2, SiteId = 5 }]; + var clock = new MutableClock(); + var cache = new RoleMappingCache(clock, new StaticMonitor(new SecurityOptions { RoleMappingCacheSeconds = ttlSeconds })); + return (repo, clock, cache, new RoleMapper(repo, cache)); + } + + /// A second mapping within the TTL reads nothing from the repository. + /// A task that represents the asynchronous operation. + [Fact] + public async Task SecondMapping_WithinTtl_IsServedFromCache() + { + var (repo, _, _, mapper) = Create(); + + var first = await mapper.MapGroupsToRolesAsync(["SiteDeployers"]); + var second = await mapper.MapGroupsToRolesAsync(["SiteDeployers", "Admins"]); + + Assert.Equal(1, repo.MappingReads); + Assert.Equal(1, repo.ScopeReads); // one Deployer mapping, read once per refresh + Assert.Equal(new[] { "5" }, first.PermittedSiteIds); + Assert.Contains(Roles.Administrator, second.Roles); + Assert.Contains(Roles.Deployer, second.Roles); + } + + /// The snapshot expires after the TTL and the next mapping re-reads the table. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Mapping_AfterTtl_RereadsRepository() + { + var (repo, clock, _, mapper) = Create(ttlSeconds: 30); + await mapper.MapGroupsToRolesAsync(["Admins"]); + + repo.Mappings.Add(Mapping(3, "Designers", Roles.Designer)); + clock.Now = clock.Now.AddSeconds(31); + var result = await mapper.MapGroupsToRolesAsync(["Designers"]); + + Assert.Equal(2, repo.MappingReads); + Assert.Contains(Roles.Designer, result.Roles); + } + + /// Invalidate drops the snapshot immediately, so a revocation is seen on the next mapping. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Invalidate_ForcesReread_RevocationVisibleImmediately() + { + var (repo, _, cache, mapper) = Create(); + Assert.Contains(Roles.Administrator, (await mapper.MapGroupsToRolesAsync(["Admins"])).Roles); + + repo.Mappings.RemoveAll(m => m.Id == 1); + cache.Invalidate(); + var after = await mapper.MapGroupsToRolesAsync(["Admins"]); + + Assert.Equal(2, repo.MappingReads); + Assert.Empty(after.Roles); + } + + /// A zero TTL disables caching entirely. + /// A task that represents the asynchronous operation. + [Fact] + public async Task ZeroTtl_DisablesCache() + { + var (repo, _, _, mapper) = Create(ttlSeconds: 0); + + await mapper.MapGroupsToRolesAsync(["Admins"]); + await mapper.MapGroupsToRolesAsync(["Admins"]); + + Assert.Equal(2, repo.MappingReads); + } + + /// The cache is shared by mappers built over different (scoped) repositories. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Cache_IsSharedAcrossScopedMappers() + { + var (repo, _, cache, mapper) = Create(); + await mapper.MapGroupsToRolesAsync(["Admins"]); + + var otherScopeRepo = new CountingRepository(); + var result = await new RoleMapper(otherScopeRepo, cache).MapGroupsToRolesAsync(["Admins"]); + + Assert.Equal(0, otherScopeRepo.MappingReads); + Assert.Contains(Roles.Administrator, result.Roles); + Assert.Equal(1, repo.MappingReads); + } +} + +/// +/// Bearer-token hardening of (review 2026-10-21 05/U1): lifetime +/// comes from , expired and unsigned tokens are +/// rejected. +/// +public class JwtBearerHardeningTests +{ + private const string Key = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough"; + + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private static JwtTokenService Service(int expiryMinutes = 15, TimeProvider? clock = null) => + new(Options.Create(new SecurityOptions { JwtSigningKey = Key, JwtExpiryMinutes = expiryMinutes }), + NullLogger.Instance, clock); + + /// The token's exp claim is JwtExpiryMinutes after issuance. + [Fact] + public void GenerateToken_ExpiryHonoursJwtExpiryMinutes() + { + var issuedAt = new DateTimeOffset(2026, 10, 22, 12, 0, 0, TimeSpan.Zero); + var token = Service(expiryMinutes: 7, clock: new FixedClock(issuedAt)) + .GenerateToken("U", "u", [Roles.Viewer], null); + + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); + + Assert.Equal(issuedAt.UtcDateTime.AddMinutes(7), jwt.ValidTo); + } + + /// A token past its expiry (beyond the small skew) is rejected. + [Fact] + public void ValidateToken_Expired_ReturnsNull() + { + var token = Service(clock: new FixedClock(DateTimeOffset.UtcNow.AddMinutes(-16))) + .GenerateToken("U", "u", [Roles.Viewer], null); + + Assert.Null(Service().ValidateToken(token)); + } + + /// An alg=none token carrying our issuer/audience is rejected. + [Fact] + public void ValidateToken_AlgNone_ReturnsNull() + { + var unsigned = new JwtSecurityToken( + issuer: JwtTokenService.TokenIssuer, + audience: JwtTokenService.TokenAudience, + claims: [new Claim(JwtTokenService.UsernameClaimType, "admin")], + expires: DateTime.UtcNow.AddMinutes(10)); + + Assert.Null(Service().ValidateToken(new JwtSecurityTokenHandler().WriteToken(unsigned))); + } + + /// A blank token is rejected without throwing. + [Fact] + public void ValidateToken_Blank_ReturnsNull() + { + Assert.Null(Service().ValidateToken(" ")); + } + + /// A non-positive JwtExpiryMinutes fails options validation. + [Fact] + public void Validator_NonPositiveExpiry_Fails() + { + var result = new SecurityOptionsValidator().Validate(null, new SecurityOptions { JwtExpiryMinutes = 0 }); + Assert.True(result.Failed); + } + + /// A negative RoleMappingCacheSeconds fails options validation. + [Fact] + public void Validator_NegativeRoleMappingCacheSeconds_Fails() + { + var result = new SecurityOptionsValidator().Validate(null, new SecurityOptions { RoleMappingCacheSeconds = -1 }); + Assert.True(result.Failed); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs index 25ed4e04..9a698bfc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs @@ -110,8 +110,7 @@ public class JwtTokenServiceTests { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, - IdleTimeoutMinutes = 30, - JwtRefreshThresholdMinutes = 5 + IdleTimeoutMinutes = 30 }; private static JwtTokenService CreateService(SecurityOptions? options = null) @@ -145,7 +144,8 @@ public class JwtTokenServiceTests Assert.Contains("1", siteIds); Assert.Contains("2", siteIds); - Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType)); + // Bearer tokens carry no idle anchor — the idle window is a cookie-session concept. + Assert.Null(principal.FindFirst(JwtTokenService.LastActivityClaimType)); } /// Verifies the case: generate Token, null Site Ids, no Site Id Claims. @@ -180,8 +180,7 @@ public class JwtTokenServiceTests { JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation", JwtExpiryMinutes = 15, - IdleTimeoutMinutes = 30, - JwtRefreshThresholdMinutes = 5 + IdleTimeoutMinutes = 30 }); var result = service2.ValidateToken(token); Assert.Null(result); @@ -201,81 +200,6 @@ public class JwtTokenServiceTests Assert.Contains("HS256", headerJson); } - /// Verifies the case: should Refresh, token Near Expiry, returns True. - [Fact] - public void ShouldRefresh_TokenNearExpiry_ReturnsTrue() - { - var options = CreateOptions(); - options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min - var service = CreateService(options); - - var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); - var principal = service.ValidateToken(token); - - Assert.True(service.ShouldRefresh(principal!)); - } - - /// Verifies the case: should Refresh, token Far From Expiry, returns False. - [Fact] - public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse() - { - var service = CreateService(); // 15 min expiry, 5 min threshold - var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); - var principal = service.ValidateToken(token); - - Assert.False(service.ShouldRefresh(principal!)); - } - - /// Verifies the case: is Idle Timed Out, recent Activity, returns False. - [Fact] - public void IsIdleTimedOut_RecentActivity_ReturnsFalse() - { - var service = CreateService(); - var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null); - var principal = service.ValidateToken(token); - - Assert.False(service.IsIdleTimedOut(principal!)); - } - - /// Verifies the case: is Idle Timed Out, no Last Activity Claim, returns True. - [Fact] - public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue() - { - var service = CreateService(); - var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(JwtTokenService.DisplayNameClaimType, "User") - })); - - Assert.True(service.IsIdleTimedOut(principal)); - } - - /// Verifies the case: refresh Token, returns New Token With Updated Claims. - [Fact] - public void RefreshToken_ReturnsNewTokenWithUpdatedClaims() - { - var service = CreateService(); - var originalToken = service.GenerateToken("User", "user", new[] { "Administrator" }, null); - var principal = service.ValidateToken(originalToken); - - var newToken = service.RefreshToken(principal!, new[] { "Administrator", "Designer" }, new[] { "1" }); - Assert.NotNull(newToken); - - var newPrincipal = service.ValidateToken(newToken!); - var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList(); - Assert.Contains("Designer", roles); - } - - /// Verifies the case: refresh Token, missing Claims, returns Null. - [Fact] - public void RefreshToken_MissingClaims_ReturnsNull() - { - var service = CreateService(); - var principal = new ClaimsPrincipal(new ClaimsIdentity()); - - var result = service.RefreshToken(principal, new[] { "Administrator" }, null); - Assert.Null(result); - } } #endregion @@ -441,8 +365,7 @@ public class SecurityReviewRegressionTests { JwtSigningKey = signingKey, JwtExpiryMinutes = 15, - IdleTimeoutMinutes = 30, - JwtRefreshThresholdMinutes = 5 + IdleTimeoutMinutes = 30 }; /// Verifies the case: jwt Token Service, empty Signing Key, throws At Construction. @@ -716,8 +639,7 @@ public class SecurityReviewRegressionTests2 { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, - IdleTimeoutMinutes = 30, - JwtRefreshThresholdMinutes = 5 + IdleTimeoutMinutes = 30 }; private static JwtTokenService CreateJwtService(SecurityOptions? options = null) => @@ -768,87 +690,6 @@ public class SecurityReviewRegressionTests2 Assert.NotNull(service.ValidateToken(token)); } - // --- refresh must preserve the original LastActivity timestamp --- - - /// Verifies the case: refresh Token, preserves Original Last Activity Claim. - [Fact] - public void RefreshToken_PreservesOriginalLastActivityClaim() - { - var service = CreateJwtService(); - - // Mint a token whose LastActivity is 20 minutes in the past (still inside the - // 30-minute idle window). Refresh must NOT move it forward to "now". - var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20); - var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(JwtTokenService.DisplayNameClaimType, "User"), - new Claim(JwtTokenService.UsernameClaimType, "user"), - new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) - }, "test")); - - var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); - Assert.NotNull(refreshed); - - var refreshedPrincipal = service.ValidateToken(refreshed!); - Assert.NotNull(refreshedPrincipal); - var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); - Assert.NotNull(claim); - Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried)); - // The carried timestamp must equal the original, not "now". - Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2, - $"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}"); - } - - /// Verifies the case: refresh Token, does Not Reset Idle Timeout When User Is Actually Idle. - [Fact] - public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle() - { - // A user idle for 25 of the 30-minute window: a refresh fired by some background - // request must not make IsIdleTimedOut flip back to false forever. - var service = CreateJwtService(); - var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25); - var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(JwtTokenService.DisplayNameClaimType, "User"), - new Claim(JwtTokenService.UsernameClaimType, "user"), - new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) - }, "test")); - - var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); - var refreshedPrincipal = service.ValidateToken(refreshed!); - - // Still 25 min idle after refresh — not reset to 0. - Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid - var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); - Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried)); - Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20, - "Refresh wrongly reset the idle clock to ~now"); - } - - /// Verifies the case: record Activity, updates Last Activity To Now. - [Fact] - public void RecordActivity_UpdatesLastActivityToNow() - { - // Genuine user activity (a real request) — distinct from a token refresh — - // updates LastActivity to the current time. - var service = CreateJwtService(); - var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20); - var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(JwtTokenService.DisplayNameClaimType, "User"), - new Claim(JwtTokenService.UsernameClaimType, "user"), - new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o")) - }, "test")); - - var touched = service.RecordActivity(principal, new[] { "Administrator" }, null); - Assert.NotNull(touched); - - var touchedPrincipal = service.ValidateToken(touched!); - var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType); - Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated)); - Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5, - "RecordActivity should set LastActivity to ~now"); - } } #endregion @@ -917,98 +758,6 @@ public class SecurityReviewRegressionTests3 #endregion -#region Code Review Regression Tests — Security-012/013/014/015 - -/// -/// Regression tests for RefreshToken re-issuing a token without -/// checking the idle timeout, so an idle-expired session can be renewed indefinitely). -/// -/// -/// Cutover: the former ExtractFirstRdnValue escaped-comma -/// parsing) and username trim before use cases tested ScadaBridge's -/// bespoke LdapAuthService.ExtractFirstRdnValue / NormalizeUsername / -/// BuildFallbackUserDn statics. Those behaviours are now owned by the shared -/// library and covered by its own tests — LdapEscapingTests.FirstRdnValue_IsEscapeAware -/// (escaped-comma RDN extraction) and LdapAuthServiceFailureTests -/// (whitespace-username → BadCredentials, no connection) — so they were removed here. -/// -public class SecurityReviewRegressionTests4 -{ - private static SecurityOptions JwtOptions() => new() - { - JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", - JwtExpiryMinutes = 15, - IdleTimeoutMinutes = 30, - JwtRefreshThresholdMinutes = 5 - }; - - private static JwtTokenService CreateJwtService(SecurityOptions? options = null) => - new(Options.Create(options ?? JwtOptions()), NullLogger.Instance); - - // --- RefreshToken must reject an idle-expired principal --- - - /// Verifies the case: refresh Token, idle Expired Principal, returns Null. - [Fact] - public void RefreshToken_IdleExpiredPrincipal_ReturnsNull() - { - // A user idle for 31 minutes (past the 30-minute idle window). Even though the - // DisplayName/Username claims are present, the refresh must be refused so an - // idle-expired session cannot be renewed by a background request. - var service = CreateJwtService(); - var idleActivity = DateTimeOffset.UtcNow.AddMinutes(-31); - var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(JwtTokenService.DisplayNameClaimType, "User"), - new Claim(JwtTokenService.UsernameClaimType, "user"), - new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o")) - }, "test")); - - var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); - - Assert.Null(refreshed); - } - - /// Verifies the case: refresh Token, active Session Within Idle Window, still Refreshes. - [Fact] - public void RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes() - { - // A user idle for 10 minutes (well inside the 30-minute window): refresh must - // still succeed — the idle-timeout guard must not break the normal sliding path. - var service = CreateJwtService(); - var recentActivity = DateTimeOffset.UtcNow.AddMinutes(-10); - var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(JwtTokenService.DisplayNameClaimType, "User"), - new Claim(JwtTokenService.UsernameClaimType, "user"), - new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o")) - }, "test")); - - var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); - - Assert.NotNull(refreshed); - } - - /// Verifies the case: refresh Token, missing Last Activity Claim, returns Null. - [Fact] - public void RefreshToken_MissingLastActivityClaim_ReturnsNull() - { - // No LastActivity claim — IsIdleTimedOut treats this as timed out, so the - // refresh must be refused rather than minting a fresh session out of nothing. - var service = CreateJwtService(); - var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] - { - new Claim(JwtTokenService.DisplayNameClaimType, "User"), - new Claim(JwtTokenService.UsernameClaimType, "user") - }, "test")); - - var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null); - - Assert.Null(refreshed); - } -} - -#endregion - #region Code Review Regression Tests — Security-012 (partial LDAP outage) /// @@ -1580,8 +1329,7 @@ public class CanonicalClaimVocabularyTests { JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough", JwtExpiryMinutes = 15, - IdleTimeoutMinutes = 30, - JwtRefreshThresholdMinutes = 5 + IdleTimeoutMinutes = 30 }; private static JwtTokenService CreateService() =>