From 5cf2d1cb99023748b6c8db306df46d4cdd4392cf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:31:19 -0400 Subject: [PATCH 1/9] docs(plan): design for dev disable-login auto-login flag (port from OtOpcUa) Faithful copy (warning only, no env guard); custom AuthenticationHandler under the cookie scheme; reuses M2.19 SessionClaimBuilder for an all-roles system-wide principal. --- docs/plans/2026-06-16-disable-login-design.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/plans/2026-06-16-disable-login-design.md diff --git a/docs/plans/2026-06-16-disable-login-design.md b/docs/plans/2026-06-16-disable-login-design.md new file mode 100644 index 00000000..5171a0df --- /dev/null +++ b/docs/plans/2026-06-16-disable-login-design.md @@ -0,0 +1,171 @@ +# Disable-Login (Dev Auto-Login) Flag — Design + +**Date:** 2026-06-16 +**Status:** Approved (design); implementation plan to follow. +**Branch:** `feature/disable-login` (off `main`, which now contains the merged M2 work). + +## Goal + +Port the sister project **OtOpcUa**'s "disable login" config flag to ScadaBridge: a dev/test +flag that, when enabled, bypasses the login form entirely and auto-authenticates **every +request** as the `multi-role` user with **all** ScadaBridge roles (full permissions, +system-wide / all sites). Default OFF. + +## Scope decisions (confirmed with user) + +- **Safety guarding:** *Faithful copy* of OtOpcUa — **no environment guard**. The flag works in + any `ASPNETCORE_ENVIRONMENT`; the only protection is a loud `LogWarning` on startup. (Noted + risk: unlike OtOpcUa's AdminUI, this disables authentication on a SCADA **control** surface — + the warning wording is emphatic and the doc-comment says "never enable in production." No code + guard, by explicit choice.) +- **Workflow:** the completed M2 (Tier-2) work was merged to `main` first (`b2d8fd8`); this + feature is built on `feature/disable-login` off that updated main, so it can reuse M2.19's + `SessionClaimBuilder`. +- **Mechanism:** a custom ASP.NET Core `AuthenticationHandler` registered **under the cookie + scheme name** when the flag is set (OtOpcUa's exact mechanism) — not request-middleware, not a + Blazor `AuthenticationStateProvider` override. + +## Reference: how OtOpcUa does it + +(Verified read-only against `/Users/dohertj2/Desktop/OtOpcUa`.) + +- Config key `Security:Auth:DisableLogin` (bool) + `Security:Auth:User` (string, default + `"multi-role-test"`), bound to `AuthDisableLoginOptions` (`SectionName = "Security:Auth"`). +- `AutoLoginAuthenticationHandler` — `HandleAuthenticateAsync` mints a `ClaimsPrincipal` with + `Name`/`Username`/`DisplayName` + one `Role` claim per `DevAuthRoles.All` (all enum role names + + `"Operator"`), returns `AuthenticateResult.Success`. **No cookie written** — every request + re-authenticates fresh. `SignInAsync`/`SignOutAsync` are explicit **no-ops** (required, because + `/auth/logout` calls `HttpContext.SignOutAsync()` on the scheme; without the no-ops the + framework throws casting the handler to a sign-out handler). +- Wiring (startup): reads the flag; if true, `AddAuthentication(Cookies)` then + `.AddScheme(Cookies, _ => {})` + **instead of** `.AddCookie(...)`. Since every authorization policy names the cookie scheme, + they all authenticate through the handler — **zero policy changes**. +- Safety: **no environment guard**; a `PostConfigure` emits one loud + `LogWarning` naming the user + roles. The login page is not hidden — it is simply never reached + because every request is already authenticated. + +## ScadaBridge adaptation + +ScadaBridge has its own role vocabulary and (post-M2.19) a canonical claim builder; the port +adapts to both. + +### 1. Config / options + +New `AuthDisableLoginOptions` in `ZB.MOM.WW.ScadaBridge.Security`: + +```csharp +public sealed class AuthDisableLoginOptions +{ + public const string SectionName = "ScadaBridge:Security:Auth"; + public bool DisableLogin { get; set; } // default false + public string User { get; set; } = "multi-role"; // canonical ScadaBridge test user +} +``` + +`appsettings.json` ships with the section absent or `"DisableLogin": false`. The flag is only set +`true` in local/dev configs (e.g. a docker-dev env var `ScadaBridge__Security__Auth__DisableLogin=true`). + +### 2. Roles — single source of truth + +ScadaBridge declares exactly four roles in `Roles.cs`: `Administrator`, `Designer`, `Deployer`, +`Viewer`. Add a `Roles.All` array (mirrors OtOpcUa's `DevAuthRoles.All`) so "all permissions" +stays in sync if a role is added: + +```csharp +public static readonly string[] All = [Administrator, Designer, Deployer, Viewer]; +``` + +### 3. Auto-login handler + +`AutoLoginAuthenticationHandler` in `ZB.MOM.WW.ScadaBridge.Security`, deriving from +`SignInAuthenticationHandler` (so it satisfies the sign-in/sign-out +handler contract). It reuses **M2.19's `SessionClaimBuilder.Build(...)`** for claim parity with a +real login: + +```csharp +var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role" : _opts.User; +var mapping = new RoleMappingResult(Roles.All, [], IsSystemWideDeployment: true); +var principal = SessionClaimBuilder.Build( + username: user, displayName: user, groups: [], mapping: mapping, refreshTimestamp: now); +return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); +``` + +This yields: `Name`/`Username`/`DisplayName` = the user; all four `Role` claims; +**no `ScopeId` claims** (`IsSystemWideDeployment: true` ⇒ system-wide, all sites). The +`LastActivity`/`LastRoleRefresh` stamps that `SessionClaimBuilder` adds are harmless here (the +auto-login scheme has no `OnValidatePrincipal`, so M2.19's idle/refresh logic is simply bypassed — +correct for a dev bypass). `now` comes from the injected `TimeProvider`. + +`SignInAsync` and `SignOutAsync` are **no-ops** (so `/auth/logout` does not throw). `_opts` is the +resolved `AuthDisableLoginOptions`. + +### 4. Wiring + +The scheme choice is build-time and config-coupled, mirroring how `AddZbLdapAuth(configuration, …)` +is called at the Host root immediately before `AddSecurity()`. Thread the flag into `AddSecurity` +(re-add a minimal parameter — the shape it had before the LDAP cutover, e.g. +`AddSecurity(this IServiceCollection services, bool disableLogin = false)`): + +- **flag false (default):** unchanged — `AddAuthentication(Cookies).AddCookie(...)` with the + existing cookie hardening + M2.19 `OnValidatePrincipal`. +- **flag true:** `AddAuthentication(Cookies).AddScheme(Cookies, _ => {})` instead of `AddCookie`, and emit the loud + startup `LogWarning`. + +`Program.cs` (Host) reads `ScadaBridge:Security:Auth:DisableLogin` from `builder.Configuration` +and passes it to `AddSecurity(...)`. `AddOptions().Bind(section)` is +registered so the handler can resolve `User`. + +### 5. Safety (faithful copy — warning only) + +No environment guard. One emphatic `LogWarning` at startup when the flag is on, e.g.: + +> "AUTH DISABLED (ScadaBridge:Security:Auth:DisableLogin=true) — every request is authenticated as +> '{User}' with FULL permissions ({Roles}) across ALL sites. This is a SCADA control surface; +> dev/test ONLY — never enable in production." + +The `AuthDisableLoginOptions` doc-comment repeats "Default OFF. Never enable in production." + +### 6. Unchanged + +Login page, `/auth/login`, `RoleMapper`, LDAP (`AddZbLdapAuth`), cookie hardening, and the M2.19 +cookie session validator are all untouched. In disable-login mode the login-redirect path is +never reached because every request is pre-authenticated. + +## Error handling + +- `HandleAuthenticateAsync` always returns success — it cannot fail (no I/O, no LDAP, no DB). +- No-op sign-in/out cannot throw. +- If `User` is blank, fall back to `"multi-role"`. + +## Testing + +xUnit, in `ZB.MOM.WW.ScadaBridge.Security.Tests` (mirroring the existing harness): + +1. **Handler** — `HandleAuthenticateAsync` returns an authenticated principal whose identity name + is the configured user, carries all four `Roles.All` role claims, and has **no `ScopeId`** + claim (system-wide). A blank/whitespace `User` falls back to `multi-role`. +2. **No-op sign-in/out** — `SignInAsync`/`SignOutAsync` complete without throwing. +3. **Registration switch** — with the flag true the registered handler for the cookie scheme is + `AutoLoginAuthenticationHandler`; with the flag false it is the cookie handler (assert via the + resolved `IAuthenticationSchemeProvider` / scheme `HandlerType`). +4. (Optional) **Claim parity** — the handler's principal matches a real-login principal shape for + an all-roles system-wide user (reuse the M2.19 `SessionClaimBuilderParityTests` idiom). + +## Files (anticipated) + +- **New:** `src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs`, + `.../Auth/AutoLoginAuthenticationHandler.cs`. +- **Modify:** `src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs` (`All` array), + `.../ServiceCollectionExtensions.cs` (flag param + scheme switch + warning + options bind), + `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (read flag, pass to `AddSecurity`). +- **Tests:** `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs` + (+ a registration-switch test). +- **Docs/deploy (optional):** note the flag in the Security component doc / docker-dev appsettings. + +## Out of scope + +- Any production safety guard (explicitly declined). +- Hiding/redirecting the login page (unnecessary — it is unreachable when auth always succeeds). +- Bearer-token (`/auth/token`) path — unaffected; this is the cookie/interactive path only. From 56d6508a5b6896407ddbbb1118b73f1df4f18cdb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:35:05 -0400 Subject: [PATCH 2/9] docs(plan): implementation plan for dev disable-login flag (4 tasks) --- docs/plans/2026-06-16-disable-login.md | 429 ++++++++++++++++++ .../2026-06-16-disable-login.md.tasks.json | 12 + 2 files changed, 441 insertions(+) create mode 100644 docs/plans/2026-06-16-disable-login.md create mode 100644 docs/plans/2026-06-16-disable-login.md.tasks.json diff --git a/docs/plans/2026-06-16-disable-login.md b/docs/plans/2026-06-16-disable-login.md new file mode 100644 index 00000000..84a35a05 --- /dev/null +++ b/docs/plans/2026-06-16-disable-login.md @@ -0,0 +1,429 @@ +# Dev Disable-Login (Auto-Login) Flag — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. (Or subagent-driven-development if executing in this session.) + +**Goal:** Add a dev/test config flag `ScadaBridge:Security:Auth:DisableLogin` that, when true, bypasses login and auto-authenticates every request as the `multi-role` user with all four ScadaBridge roles, system-wide. + +**Architecture:** Faithful port of OtOpcUa's mechanism — a custom `AuthenticationHandler` registered **under the cookie scheme name** when the flag is set, so all authorization policies (which name that scheme) authenticate through it with zero policy changes. The minted principal reuses M2.19's `SessionClaimBuilder` for claim parity. No-op sign-in/out (so `/auth/logout` doesn't throw). Loud startup warning; **no environment guard** (per design decision). + +**Tech Stack:** C#/.NET 10, ASP.NET Core cookie authentication, xUnit + NSubstitute. Design doc: `docs/plans/2026-06-16-disable-login-design.md`. Branch: `feature/disable-login` (off `main`, M2 merged — so `SessionClaimBuilder` is present). + +**Build/test scope:** targeted per-task — build only the affected project(s) (`dotnet build src//.csproj`), run only `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter `. TreatWarningsAsErrors is ON (0 warnings). One full-solution build (`dotnet build ZB.MOM.WW.ScadaBridge.slnx`) at the very end before declaring done. + +**Reference (verbatim) — OtOpcUa handler** at `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AutoLoginAuthenticationHandler.cs`: derives `AuthenticationHandler, IAuthenticationSignInHandler`; ctor `(IOptionsMonitor, ILoggerFactory, UrlEncoder, IOptions)`; no-op `SignInAsync`/`SignOutAsync`; `HandleAuthenticateAsync` mints the principal and returns `AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name))`. + +--- + +## Task 1: Options class + `Roles.All` + +**Classification:** small +**Estimated implement time:** ~3 min +**Parallelizable with:** none + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs` +- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs` (add `All` array after line 40) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs` (new) + +**Step 1: Create the options class** + +```csharp +// src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs +namespace ZB.MOM.WW.ScadaBridge.Security.Auth; + +/// +/// Dev/test flag: when is true the Central UI bypasses the login +/// form entirely and auto-authenticates EVERY request as with ALL roles, +/// system-wide. Default OFF. This disables authentication on a SCADA control surface — +/// dev/test ONLY; never enable in production. +/// +public sealed class AuthDisableLoginOptions +{ + /// Configuration section name (ScadaBridge:Security:Auth). + public const string SectionName = "ScadaBridge:Security:Auth"; + + /// When true, disable login and auto-authenticate every request. Default false. + public bool DisableLogin { get; set; } + + /// The username the auto-login principal is minted with. Default "multi-role". + public string User { get; set; } = "multi-role"; +} +``` + +**Step 2: Add `Roles.All`** — in `Roles.cs`, after the `Viewer` const (line 40): + +```csharp + /// All declared ScadaBridge roles — the single source of truth for "all + /// permissions" (e.g. the dev auto-login principal). Stays in sync if a role is added. + public static readonly string[] All = [Administrator, Designer, Deployer, Viewer]; +``` + +**Step 3: Write + run the guard test** + +```csharp +// tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs +using ZB.MOM.WW.ScadaBridge.Security; +using Xunit; + +public class RolesAllTests +{ + [Fact] + public void All_ContainsEveryDeclaredRole() + { + Assert.Equal( + new[] { Roles.Administrator, Roles.Designer, Roles.Deployer, Roles.Viewer }, + Roles.All); + } +} +``` + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter "FullyQualifiedName~RolesAllTests"` → PASS. + +**Step 4: Build + commit** + +`dotnet build src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj` (0 warnings), then: +```bash +git add src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs \ + src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs \ + tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs +git commit -m "feat(security): AuthDisableLoginOptions + Roles.All for dev auto-login" +``` + +**Acceptance:** options class exists with `SectionName="ScadaBridge:Security:Auth"`, `User="multi-role"`; `Roles.All` = the four roles; test green. + +--- + +## Task 2: `AutoLoginAuthenticationHandler` + unit tests + +**Classification:** high-risk *(security — grants all roles / bypasses auth; warrants the serial spec→code review chain)* +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Depends on:** Task 1 + +**Files:** +- Create: `src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs` +- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs` (new) + +**Step 1: Implement the handler** (reuses `SessionClaimBuilder` for claim parity; mirrors the OtOpcUa shape): + +```csharp +// src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Claims; + +namespace ZB.MOM.WW.ScadaBridge.Security.Auth; + +/// +/// Auth handler used ONLY when is true. +/// Registered under the cookie scheme name, it authenticates EVERY request as the configured +/// dev user with all roles, system-wide — no credential check, no cookie. +/// The minted principal mirrors a real login (it reuses ). +/// Dev/test ONLY. +/// +public sealed class AutoLoginAuthenticationHandler + : AuthenticationHandler, IAuthenticationSignInHandler +{ + private readonly AuthDisableLoginOptions _opts; + private readonly TimeProvider _clock; + + /// Initializes the handler with the scheme plumbing, the disable-login options, and the clock. + public AutoLoginAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IOptions disableLoginOptions, + TimeProvider clock) + : base(options, logger, encoder) + { + _opts = disableLoginOptions.Value; + _clock = clock; + } + + /// No-op: auto-login writes no cookie, so an explicit sign-in has nothing to persist. + public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) => Task.CompletedTask; + + /// No-op: there is no auth cookie to clear; the next request re-authenticates via this handler. + public Task SignOutAsync(AuthenticationProperties? properties) => Task.CompletedTask; + + /// + protected override Task HandleAuthenticateAsync() + { + var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role" : _opts.User; + + // All roles, system-wide (no site-scope claims). Reuse the canonical builder so the + // principal is byte-shape-identical to a real all-roles system-wide login. + var mapping = new RoleMappingResult(Roles.All, [], IsSystemWideDeployment: true); + var principal = SessionClaimBuilder.Build( + username: user, + displayName: user, + groups: [], + mapping: mapping, + refreshTimestamp: _clock.GetUtcNow()); + + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} +``` + +**Step 2: Write the failing tests** (xUnit + the repo's existing patterns; `AuthenticationHandler` needs `InitializeAsync` before `AuthenticateAsync`): + +```csharp +// tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.Security.Auth; +using Xunit; + +public class AutoLoginAuthenticationHandlerTests +{ + private static async Task CreateAsync(string user = "multi-role") + { + var schemeOptions = Substitute.For>(); + schemeOptions.Get(Arg.Any()).Returns(new AuthenticationSchemeOptions()); + var opts = Options.Create(new AuthDisableLoginOptions { DisableLogin = true, User = user }); + + var handler = new AutoLoginAuthenticationHandler( + schemeOptions, NullLoggerFactory.Instance, UrlEncoder.Default, opts, TimeProvider.System); + + await handler.InitializeAsync( + new AuthenticationScheme( + CookieAuthenticationDefaults.AuthenticationScheme, null, typeof(AutoLoginAuthenticationHandler)), + new DefaultHttpContext()); + return handler; + } + + [Fact] + public async Task Authenticate_GrantsAllRoles_SystemWide_AsConfiguredUser() + { + var handler = await CreateAsync("multi-role"); + + var result = await handler.AuthenticateAsync(); + + Assert.True(result.Succeeded); + var p = result.Principal!; + Assert.Equal("multi-role", p.Identity!.Name); + foreach (var role in Roles.All) + Assert.True(p.IsInRole(role), $"expected role {role}"); + // System-wide ⇒ no ScopeId/site claims. + Assert.Empty(p.FindAll(JwtTokenService.SiteIdClaimType)); + } + + [Fact] + public async Task Authenticate_BlankUser_FallsBackToMultiRole() + { + var handler = await CreateAsync(" "); + var result = await handler.AuthenticateAsync(); + Assert.Equal("multi-role", result.Principal!.Identity!.Name); + } + + [Fact] + public async Task SignInAndSignOut_AreNoOps_DoNotThrow() + { + var handler = await CreateAsync(); + await handler.SignInAsync(new ClaimsPrincipal(), null); // no throw + await handler.SignOutAsync(null); // no throw + } +} +``` + +Run (expect FAIL first — handler not yet compiled / then PASS): +`dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter "FullyQualifiedName~AutoLoginAuthenticationHandlerTests"` + +**Step 3: Build + run + commit** + +`dotnet build src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj` (0 warnings); tests green, then: +```bash +git add src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs \ + tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs +git commit -m "feat(security): AutoLoginAuthenticationHandler — all-roles system-wide dev auto-login (#disable-login)" +``` + +**Acceptance:** handler authenticates every request as the configured user with all four roles and **no** site-scope claims; blank user → `multi-role`; sign-in/out no-ops don't throw; claim shape matches a real all-roles system-wide login (via `SessionClaimBuilder`). + +--- + +## Task 3: Wire the flag into `AddSecurity` + Host, with startup warning + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** none +**Depends on:** Task 1, Task 2 + +**Files:** +- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs` (the `AddSecurity` signature ~line 40 + the `AddAuthentication(...).AddCookie(...)` block ~line 107) +- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (~line 124, the `AddSecurity()` call) +- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs` (new) + +**Context:** `AddSecurity()` currently takes no config (Options-pattern only; the Host owns config-coupled wiring like `AddZbLdapAuth`). The scheme choice is build-time, so the flag is passed in as a `bool`. The Host binds `AuthDisableLoginOptions` (so the handler can resolve `User`) and reads the flag. + +**Step 1: Change `AddSecurity` to accept the flag and branch the scheme.** + +Change the signature: +```csharp +public static IServiceCollection AddSecurity(this IServiceCollection services, bool disableLogin = false) +``` + +Replace the `services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => { ... });` registration (~lines 107–128) with a branch: + +```csharp +var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); +if (disableLogin) +{ + // DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered + // UNDER the cookie scheme name, so every authorization policy (which names this scheme) + // authenticates through it with all roles — zero policy changes. No cookie is written; + // OnValidatePrincipal (idle/refresh) does not apply in this mode. See AuthDisableLoginOptions. + authBuilder.AddScheme( + CookieAuthenticationDefaults.AuthenticationScheme, _ => { }); + + // Loud, once-at-first-resolve warning (mirrors OtOpcUa). + services.AddOptions() + .PostConfigure((opts, lf) => + lf.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning( + "AUTH DISABLED (ScadaBridge:Security:Auth:DisableLogin=true) — every request is " + + "authenticated as '{User}' with FULL permissions ({Roles}) across ALL sites. This " + + "is a SCADA control surface; dev/test ONLY — never enable in production.", + opts.User, string.Join(",", Roles.All))); +} +else +{ + authBuilder.AddCookie(options => + { + options.LoginPath = "/login"; + options.LogoutPath = "/auth/logout"; + options.Events.OnValidatePrincipal = OnValidatePrincipalAsync; + }); +} +``` + +Keep the existing `services.AddOptions(...)` PostConfigure (cookie hardening) as-is — it is harmless when the auto-login scheme is active (no cookie handler consumes it). Add a brief comment noting that. + +**Step 2: Wire the Host** — in `Program.cs`, replace `builder.Services.AddSecurity();` (~line 124) with: + +```csharp +// Dev disable-login flag (config-coupled, so read + bound here at the composition root, +// mirroring AddZbLdapAuth). Default false. See AuthDisableLoginOptions / disable-login design doc. +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection( + ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName)); +var disableLogin = builder.Configuration + .GetSection(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName) + .GetValue(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.DisableLogin)); +builder.Services.AddSecurity(disableLogin); +``` + +(Confirm the needed `using Microsoft.Extensions.Logging;` is present in `ServiceCollectionExtensions.cs` for the warning; add if missing — TreatWarningsAsErrors will flag an unused using, so only add if used.) + +**Step 3: Registration-switch test** — assert the scheme's handler type flips with the flag: + +```csharp +// tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.Security.Auth; +using Xunit; + +public class DisableLoginRegistrationTests +{ + private static async Task ResolveCookieSchemeAsync(bool disableLogin) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSecurity(disableLogin); + await using var sp = services.BuildServiceProvider(); + var provider = sp.GetRequiredService(); + return await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } + + [Fact] + public async Task FlagTrue_RegistersAutoLoginHandlerUnderCookieScheme() + { + var scheme = await ResolveCookieSchemeAsync(disableLogin: true); + Assert.Equal(typeof(AutoLoginAuthenticationHandler), scheme!.HandlerType); + } + + [Fact] + public async Task FlagFalse_RegistersCookieHandler() + { + var scheme = await ResolveCookieSchemeAsync(disableLogin: false); + Assert.Equal(typeof(CookieAuthenticationHandler), scheme!.HandlerType); + } +} +``` + +> NOTE for the implementer: `AddSecurity` may resolve other dependencies; if `BuildServiceProvider` throws because a collaborator needs more registrations, register the minimal extras the test needs (or assert against the `AuthenticationSchemeOptions`/scheme map another way). Keep the test focused on the scheme→handler-type switch. If full `AddSecurity` resolution is impractical in a unit test, narrow the test to call only the auth-registration portion (extract a small internal helper if needed) — surface this as a plan note rather than over-registering. + +Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter "FullyQualifiedName~DisableLoginRegistrationTests"` + +**Step 4: Build + commit** + +`dotnet build src/ZB.MOM.WW.ScadaBridge.Security/...csproj` and `dotnet build src/ZB.MOM.WW.ScadaBridge.Host/...csproj` (0 warnings); tests green, then: +```bash +git add src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs \ + src/ZB.MOM.WW.ScadaBridge.Host/Program.cs \ + tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs +git commit -m "feat(security): wire DisableLogin flag — auto-login scheme + startup warning (#disable-login)" +``` + +**Acceptance:** flag true → cookie scheme resolves to `AutoLoginAuthenticationHandler` + loud warning logged; flag false → unchanged cookie handler + M2.19 `OnValidatePrincipal`; Host reads/binds the flag. + +--- + +## Task 4: Docs + dev config note + +**Classification:** trivial +**Estimated implement time:** ~3 min +**Parallelizable with:** none +**Depends on:** Task 3 + +**Files:** +- Modify: `docs/requirements/Component-Security.md` (add a short "Dev disable-login flag" subsection) +- Modify: the dev/docker appsettings used for local runs (e.g. `docker/central-node-a/appsettings.Central.json` and `-b`, OR `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json`) — add the `ScadaBridge:Security:Auth:DisableLogin: false` key as discoverable documentation (shipped **false**). + +**Step 1:** In `Component-Security.md`, add: + +> **Dev disable-login flag (`ScadaBridge:Security:Auth:DisableLogin`)** — when `true`, the Central +> UI bypasses login and auto-authenticates every request as `Security:Auth:User` (default +> `multi-role`) with all roles, system-wide, via `AutoLoginAuthenticationHandler` registered under +> the cookie scheme. Default `false`. **No environment guard** — a loud startup warning is the +> only protection. Dev/test ONLY; never enable in production. Set via env var +> `ScadaBridge__Security__Auth__DisableLogin=true` in local/docker-dev. + +**Step 2:** Add the key (value `false`) to the chosen appsettings file(s) so it is discoverable. + +**Step 3: Commit** +```bash +git add docs/requirements/Component-Security.md +git commit -m "docs(security): document dev disable-login flag (#disable-login)" +``` + +**Acceptance:** the flag is documented in the security component doc and visible (false) in a dev appsettings. + +--- + +## Final step (after all tasks) + +Run the one full-solution build and the Security suite: +- `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 warnings / 0 errors. +- `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests` → green. + +Then finishing-a-development-branch (merge/push is the user's call). + +## Cross-cutting notes +- DRY: the handler reuses `SessionClaimBuilder` — do NOT hand-roll claims. +- YAGNI: no environment guard, no login-page hiding (unreachable when auth always succeeds), no Bearer-path change. +- The auto-login scheme has no `OnValidatePrincipal`; M2.19 idle/refresh is intentionally bypassed in this dev mode. diff --git a/docs/plans/2026-06-16-disable-login.md.tasks.json b/docs/plans/2026-06-16-disable-login.md.tasks.json new file mode 100644 index 00000000..41213110 --- /dev/null +++ b/docs/plans/2026-06-16-disable-login.md.tasks.json @@ -0,0 +1,12 @@ +{ + "planPath": "docs/plans/2026-06-16-disable-login.md", + "designDoc": "docs/plans/2026-06-16-disable-login-design.md", + "branch": "feature/disable-login", + "tasks": [ + {"id": 62, "ref": "DL-1", "subject": "AuthDisableLoginOptions + Roles.All", "class": "small", "status": "pending"}, + {"id": 63, "ref": "DL-2", "subject": "AutoLoginAuthenticationHandler + tests", "class": "high-risk", "status": "pending", "blockedBy": [62]}, + {"id": 64, "ref": "DL-3", "subject": "Wire flag into AddSecurity + Host + startup warning", "class": "standard", "status": "pending", "blockedBy": [62, 63]}, + {"id": 65, "ref": "DL-4", "subject": "Docs + dev config note", "class": "trivial", "status": "pending", "blockedBy": [64]} + ], + "lastUpdated": "2026-06-16" +} From 72691e5577b4021fbb675d09f3fae048bbe4bcad Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:36:48 -0400 Subject: [PATCH 3/9] feat(security): AuthDisableLoginOptions + Roles.All for dev auto-login --- .../Auth/AuthDisableLoginOptions.cs | 19 +++++++++++++++++++ src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs | 4 ++++ .../RolesAllTests.cs | 15 +++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs new file mode 100644 index 00000000..ff8b1606 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs @@ -0,0 +1,19 @@ +namespace ZB.MOM.WW.ScadaBridge.Security.Auth; + +/// +/// Dev/test flag: when is true the Central UI bypasses the login +/// form entirely and auto-authenticates EVERY request as with ALL roles, +/// system-wide. Default OFF. This disables authentication on a SCADA control surface — +/// dev/test ONLY; never enable in production. +/// +public sealed class AuthDisableLoginOptions +{ + /// Configuration section name (ScadaBridge:Security:Auth). + public const string SectionName = "ScadaBridge:Security:Auth"; + + /// When true, disable login and auto-authenticate every request. Default false. + public bool DisableLogin { get; set; } + + /// The username the auto-login principal is minted with. Default "multi-role". + public string User { get; set; } = "multi-role"; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs b/src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs index 2e1f4ccc..b3d90f45 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs @@ -38,4 +38,8 @@ public static class Roles public const string Designer = "Designer"; public const string Deployer = "Deployer"; public const string Viewer = "Viewer"; + + /// All declared ScadaBridge roles — the single source of truth for "all + /// permissions" (e.g. the dev auto-login principal). Stays in sync if a role is added. + public static readonly string[] All = [Administrator, Designer, Deployer, Viewer]; } diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs new file mode 100644 index 00000000..1488d6c7 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs @@ -0,0 +1,15 @@ +using ZB.MOM.WW.ScadaBridge.Security; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.Security.Tests; + +public class RolesAllTests +{ + [Fact] + public void All_ContainsEveryDeclaredRole() + { + Assert.Equal( + new[] { Roles.Administrator, Roles.Designer, Roles.Deployer, Roles.Viewer }, + Roles.All); + } +} From dcd445a380ad8b882b4ce6eb448f932da4bec687 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:40:30 -0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(security):=20AutoLoginAuthenticationHa?= =?UTF-8?q?ndler=20=E2=80=94=20all-roles=20system-wide=20dev=20auto-login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-16-disable-login.md.tasks.json | 4 +- .../Auth/AutoLoginAuthenticationHandler.cs | 60 +++++++++++++++ .../AutoLoginAuthenticationHandlerTests.cs | 73 +++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs diff --git a/docs/plans/2026-06-16-disable-login.md.tasks.json b/docs/plans/2026-06-16-disable-login.md.tasks.json index 41213110..087f4a86 100644 --- a/docs/plans/2026-06-16-disable-login.md.tasks.json +++ b/docs/plans/2026-06-16-disable-login.md.tasks.json @@ -3,8 +3,8 @@ "designDoc": "docs/plans/2026-06-16-disable-login-design.md", "branch": "feature/disable-login", "tasks": [ - {"id": 62, "ref": "DL-1", "subject": "AuthDisableLoginOptions + Roles.All", "class": "small", "status": "pending"}, - {"id": 63, "ref": "DL-2", "subject": "AutoLoginAuthenticationHandler + tests", "class": "high-risk", "status": "pending", "blockedBy": [62]}, + {"id": 62, "ref": "DL-1", "subject": "AuthDisableLoginOptions + Roles.All", "class": "small", "status": "completed", "commits": ["72691e5"]}, + {"id": 63, "ref": "DL-2", "subject": "AutoLoginAuthenticationHandler + tests", "class": "high-risk", "status": "completed", "blockedBy": [62]}, {"id": 64, "ref": "DL-3", "subject": "Wire flag into AddSecurity + Host + startup warning", "class": "standard", "status": "pending", "blockedBy": [62, 63]}, {"id": 65, "ref": "DL-4", "subject": "Docs + dev config note", "class": "trivial", "status": "pending", "blockedBy": [64]} ], diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs b/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs new file mode 100644 index 00000000..c170f863 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.ScadaBridge.Security.Auth; + +/// +/// Auth handler used ONLY when is true. +/// Registered under the cookie scheme name, it authenticates EVERY request as the configured +/// dev user with all roles, system-wide — no credential check, no cookie. +/// The minted principal mirrors a real login (it reuses ). +/// Dev/test ONLY. +/// +public sealed class AutoLoginAuthenticationHandler + : AuthenticationHandler, IAuthenticationSignInHandler +{ + private readonly AuthDisableLoginOptions _opts; + private readonly TimeProvider _clock; + + /// Initializes the handler with the scheme plumbing, the disable-login options, and the clock. + public AutoLoginAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IOptions disableLoginOptions, + TimeProvider clock) + : base(options, logger, encoder) + { + _opts = disableLoginOptions.Value; + _clock = clock; + } + + /// No-op: auto-login writes no cookie, so an explicit sign-in has nothing to persist. + public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) => Task.CompletedTask; + + /// No-op: there is no auth cookie to clear; the next request re-authenticates via this handler. + public Task SignOutAsync(AuthenticationProperties? properties) => Task.CompletedTask; + + /// + protected override Task HandleAuthenticateAsync() + { + var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role" : _opts.User; + + // All roles, system-wide (no site-scope claims). Reuse the canonical builder so the + // principal is shape-identical to a real all-roles system-wide login. + var mapping = new RoleMappingResult(Roles.All, [], IsSystemWideDeployment: true); + var principal = SessionClaimBuilder.Build( + username: user, + displayName: user, + groups: [], + mapping: mapping, + refreshTimestamp: _clock.GetUtcNow()); + + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs new file mode 100644 index 00000000..124665b1 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs @@ -0,0 +1,73 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.Security.Auth; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.Security.Tests; + +public class AutoLoginAuthenticationHandlerTests +{ + /// + /// Minimal stub for the scheme options the base + /// resolves during InitializeAsync. This + /// test project deliberately carries no mocking library, so the seam is hand-rolled. + /// + private sealed class StubOptionsMonitor : IOptionsMonitor + { + private readonly AuthenticationSchemeOptions _value = new(); + public AuthenticationSchemeOptions CurrentValue => _value; + public AuthenticationSchemeOptions Get(string? name) => _value; + public IDisposable? OnChange(Action listener) => null; + } + + private static async Task CreateAsync(string user = "multi-role") + { + var schemeOptions = new StubOptionsMonitor(); + var opts = Options.Create(new AuthDisableLoginOptions { DisableLogin = true, User = user }); + + var handler = new AutoLoginAuthenticationHandler( + schemeOptions, NullLoggerFactory.Instance, UrlEncoder.Default, opts, TimeProvider.System); + + await handler.InitializeAsync( + new AuthenticationScheme( + CookieAuthenticationDefaults.AuthenticationScheme, null, typeof(AutoLoginAuthenticationHandler)), + new DefaultHttpContext()); + return handler; + } + + [Fact] + public async Task Authenticate_GrantsAllRoles_SystemWide_AsConfiguredUser() + { + var handler = await CreateAsync("multi-role"); + var result = await handler.AuthenticateAsync(); + + Assert.True(result.Succeeded); + var p = result.Principal!; + Assert.Equal("multi-role", p.Identity!.Name); + foreach (var role in Roles.All) + Assert.True(p.IsInRole(role), $"expected role {role}"); + Assert.Empty(p.FindAll(JwtTokenService.SiteIdClaimType)); // system-wide => no scope claims + } + + [Fact] + public async Task Authenticate_BlankUser_FallsBackToMultiRole() + { + var handler = await CreateAsync(" "); + var result = await handler.AuthenticateAsync(); + Assert.Equal("multi-role", result.Principal!.Identity!.Name); + } + + [Fact] + public async Task SignInAndSignOut_AreNoOps_DoNotThrow() + { + var handler = await CreateAsync(); + await handler.SignInAsync(new ClaimsPrincipal(), null); + await handler.SignOutAsync(null); + } +} From 0926ce4dda69e4bd64b8638a7d7991b49e3e5a2f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:44:06 -0400 Subject: [PATCH 5/9] =?UTF-8?q?test(security):=20DL-2=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20assert=20IsAuthenticated=20+=20clarify=20handler=20?= =?UTF-8?q?flag=20gating?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/AutoLoginAuthenticationHandler.cs | 3 +++ .../AutoLoginAuthenticationHandlerTests.cs | 1 + 2 files changed, 4 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs b/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs index c170f863..0ff6137e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs @@ -17,6 +17,9 @@ namespace ZB.MOM.WW.ScadaBridge.Security.Auth; public sealed class AutoLoginAuthenticationHandler : AuthenticationHandler, IAuthenticationSignInHandler { + // Only _opts.User is consumed here. The DisableLogin flag is gated at registration time + // (AddSecurity); if DisableLogin is false this handler is never registered, so it is + // never reached and the flag itself is irrelevant inside the handler. private readonly AuthDisableLoginOptions _opts; private readonly TimeProvider _clock; diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs index 124665b1..33662e13 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs @@ -49,6 +49,7 @@ public class AutoLoginAuthenticationHandlerTests Assert.True(result.Succeeded); var p = result.Principal!; + Assert.True(p.Identity!.IsAuthenticated); // first gate checked by [Authorize] + Blazor AuthenticationStateProvider Assert.Equal("multi-role", p.Identity!.Name); foreach (var role in Roles.All) Assert.True(p.IsInRole(role), $"expected role {role}"); From e89604298dafbff844cb0b4ef994b9e23c72ad0e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:47:19 -0400 Subject: [PATCH 6/9] =?UTF-8?q?feat(security):=20wire=20DisableLogin=20fla?= =?UTF-8?q?g=20=E2=80=94=20auto-login=20scheme=20+=20startup=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-16-disable-login.md.tasks.json | 4 +-- src/ZB.MOM.WW.ScadaBridge.Host/Program.cs | 10 +++++- .../ServiceCollectionExtensions.cs | 35 +++++++++++++++++-- .../DisableLoginRegistrationTests.cs | 35 +++++++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs diff --git a/docs/plans/2026-06-16-disable-login.md.tasks.json b/docs/plans/2026-06-16-disable-login.md.tasks.json index 087f4a86..03f87887 100644 --- a/docs/plans/2026-06-16-disable-login.md.tasks.json +++ b/docs/plans/2026-06-16-disable-login.md.tasks.json @@ -4,8 +4,8 @@ "branch": "feature/disable-login", "tasks": [ {"id": 62, "ref": "DL-1", "subject": "AuthDisableLoginOptions + Roles.All", "class": "small", "status": "completed", "commits": ["72691e5"]}, - {"id": 63, "ref": "DL-2", "subject": "AutoLoginAuthenticationHandler + tests", "class": "high-risk", "status": "completed", "blockedBy": [62]}, - {"id": 64, "ref": "DL-3", "subject": "Wire flag into AddSecurity + Host + startup warning", "class": "standard", "status": "pending", "blockedBy": [62, 63]}, + {"id": 63, "ref": "DL-2", "subject": "AutoLoginAuthenticationHandler + tests", "class": "high-risk", "status": "completed", "blockedBy": [62], "commits": ["dcd445a", "0926ce4"]}, + {"id": 64, "ref": "DL-3", "subject": "Wire flag into AddSecurity + Host + startup warning", "class": "standard", "status": "completed", "blockedBy": [62, 63]}, {"id": 65, "ref": "DL-4", "subject": "Docs + dev config note", "class": "trivial", "status": "pending", "blockedBy": [64]} ], "lastUpdated": "2026-06-16" diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs index 022e296a..3f52a85e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Program.cs @@ -121,7 +121,15 @@ try builder.Services.AddZbLdapAuth( builder.Configuration, ZB.MOM.WW.ScadaBridge.Security.ServiceCollectionExtensions.LdapSectionPath); - builder.Services.AddSecurity(); + // Dev disable-login flag (config-coupled, so read + bound here at the composition root, + // mirroring AddZbLdapAuth). Default false. See AuthDisableLoginOptions / disable-login design doc. + builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection( + ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName)); + var disableLogin = builder.Configuration + .GetSection(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName) + .GetValue(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.DisableLogin)); + builder.Services.AddSecurity(disableLogin); builder.Services.AddCentralUI(); builder.Services.AddInboundAPI(); diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs index 9230efe9..f9c3022b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs @@ -36,8 +36,15 @@ public static class ServiceCollectionExtensions /// IConfiguration) and component libraries must not accept IConfiguration. /// /// The service collection to register into. + /// + /// Dev/test flag (bound from by the Host + /// composition root). When true the cookie handler is replaced — under the same cookie + /// scheme name — by an always-succeeding + /// that authenticates every request as the configured dev user with ALL roles, and a loud + /// startup warning is emitted. Default false. Never enable in production. + /// /// The same instance for chaining. - public static IServiceCollection AddSecurity(this IServiceCollection services) + public static IServiceCollection AddSecurity(this IServiceCollection services, bool disableLogin = false) { // Task 1.2 cutover: ScadaBridge's bespoke LdapAuthService was replaced by the // shared ZB.MOM.WW.Auth.Ldap implementation (ScadaBridge was the donor for its @@ -104,8 +111,27 @@ public static class ServiceCollectionExtensions // timeout, HTTPS policy) are applied via the SecurityOptions-bound PostConfigure // below (cookie name through SecurityOptions.CookieName, the rest through // ZbCookieDefaults.Apply). - services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => + var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); + if (disableLogin) + { + // DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered + // UNDER the cookie scheme name, so every authorization policy (which names this scheme) + // authenticates through it with all roles — zero policy changes. No cookie is written; + // OnValidatePrincipal (idle/refresh) does not apply in this mode. See AuthDisableLoginOptions. + authBuilder.AddScheme( + CookieAuthenticationDefaults.AuthenticationScheme, _ => { }); + + services.AddOptions() + .PostConfigure((opts, lf) => + lf.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning( + "AUTH DISABLED (ScadaBridge:Security:Auth:DisableLogin=true) — every request is " + + "authenticated as '{User}' with FULL permissions ({Roles}) across ALL sites. This " + + "is a SCADA control surface; dev/test ONLY — never enable in production.", + opts.User, string.Join(",", Roles.All))); + } + else + { + authBuilder.AddCookie(options => { options.LoginPath = "/login"; options.LogoutPath = "/auth/logout"; @@ -126,6 +152,7 @@ public static class ServiceCollectionExtensions // keeps the session (mirrors "LDAP failure: active sessions continue"). options.Events.OnValidatePrincipal = OnValidatePrincipalAsync; }); + } // CentralUI-005: configure the cookie session as a sliding window so the // code matches the documented policy ("sliding refresh, 30-minute idle @@ -147,6 +174,8 @@ public static class ServiceCollectionExtensions // the shared ZbCookieDefaults.Apply, with requireHttps + idleTimeout driven // by SecurityOptions so behaviour (30-min sliding idle window, HTTPS-only // unless explicitly opted out) is preserved. + // Harmless/unused when disableLogin is true: the cookie handler is not registered, + // so this CookieAuthenticationOptions PostConfigure has no scheme to configure. services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme) .Configure, ILoggerFactory>((cookieOptions, securityOptions, loggerFactory) => { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs new file mode 100644 index 00000000..c84fb094 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.Security.Auth; +using Xunit; + +namespace ZB.MOM.WW.ScadaBridge.Security.Tests; + +public class DisableLoginRegistrationTests +{ + private static async Task ResolveCookieSchemeAsync(bool disableLogin) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSecurity(disableLogin); + await using var sp = services.BuildServiceProvider(); + var provider = sp.GetRequiredService(); + return await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme); + } + + [Fact] + public async Task FlagTrue_RegistersAutoLoginHandlerUnderCookieScheme() + { + var scheme = await ResolveCookieSchemeAsync(disableLogin: true); + Assert.Equal(typeof(AutoLoginAuthenticationHandler), scheme!.HandlerType); + } + + [Fact] + public async Task FlagFalse_RegistersCookieHandler() + { + var scheme = await ResolveCookieSchemeAsync(disableLogin: false); + Assert.Equal(typeof(CookieAuthenticationHandler), scheme!.HandlerType); + } +} From 75919cec31164888975922a6b5d338462d785ec4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:52:28 -0400 Subject: [PATCH 7/9] =?UTF-8?q?test(security):=20DL-3=20review=20nits=20?= =?UTF-8?q?=E2=80=94=20assert=20OnValidatePrincipal=20on=20prod=20path=20+?= =?UTF-8?q?=20warning/doc=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Auth/AuthDisableLoginOptions.cs | 10 ++++++- .../ServiceCollectionExtensions.cs | 2 +- .../DisableLoginRegistrationTests.cs | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs index ff8b1606..ba7d9fa8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs @@ -8,7 +8,15 @@ namespace ZB.MOM.WW.ScadaBridge.Security.Auth; /// public sealed class AuthDisableLoginOptions { - /// Configuration section name (ScadaBridge:Security:Auth). + /// + /// Configuration section name (ScadaBridge:Security:Auth). + /// This is a CHILD sub-section of ScadaBridge:Security (where + /// binds the parent fields) — not a sibling. + /// In appsettings.json nest it under the existing Security object: + /// + /// "ScadaBridge": { "Security": { "Auth": { "DisableLogin": true } } } + /// + /// public const string SectionName = "ScadaBridge:Security:Auth"; /// When true, disable login and auto-authenticate every request. Default false. diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs index f9c3022b..94edacac 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs @@ -127,7 +127,7 @@ public static class ServiceCollectionExtensions "AUTH DISABLED (ScadaBridge:Security:Auth:DisableLogin=true) — every request is " + "authenticated as '{User}' with FULL permissions ({Roles}) across ALL sites. This " + "is a SCADA control surface; dev/test ONLY — never enable in production.", - opts.User, string.Join(",", Roles.All))); + opts.User, string.Join(", ", Roles.All))); } else { diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs index c84fb094..c4075ba3 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs @@ -32,4 +32,30 @@ public class DisableLoginRegistrationTests var scheme = await ResolveCookieSchemeAsync(disableLogin: false); Assert.Equal(typeof(CookieAuthenticationHandler), scheme!.HandlerType); } + + /// + /// When disableLogin: false (the production path) the M2.19 idle-timeout / + /// role-refresh hook MUST be wired on the cookie scheme's + /// . + /// This pin-test ensures a future refactor cannot silently drop the hook without + /// a red test. + /// + [Fact] + public async Task FlagFalse_CookieScheme_OnValidatePrincipalIsWired() + { + var services = new ServiceCollection(); + services.AddLogging(); + // Provide default SecurityOptions so the PostConfigure that reads + // IOptions (cookie-hardening + name) can resolve successfully. + services.Configure(_ => { }); + services.AddSecurity(disableLogin: false); + + await using var sp = services.BuildServiceProvider(); + + var options = sp + .GetRequiredService>() + .Get(CookieAuthenticationDefaults.AuthenticationScheme); + + Assert.NotNull(options.Events?.OnValidatePrincipal); + } } From 57302500acf9c52a794aa1bc724de90e3c3efc00 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:54:11 -0400 Subject: [PATCH 8/9] docs(security): document dev disable-login flag + ship default-false config key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Dev Disable-Login Flag" subsection to Component-Security.md covering ScadaBridge:Security:Auth:DisableLogin / User, the AutoLoginAuthenticationHandler mechanism, and the no-environment-guard / startup-warning production risk. Ships DisableLogin: false under ScadaBridge → Security → Auth in: - src/.../Host/appsettings.json (canonical default) - docker/central-node-a/appsettings.Central.json - docker/central-node-b/appsettings.Central.json Also records DL-3 commit SHAs in the plan tasks file. --- docker/central-node-a/appsettings.Central.json | 4 ++++ docker/central-node-b/appsettings.Central.json | 4 ++++ docs/plans/2026-06-16-disable-login.md.tasks.json | 2 +- docs/requirements/Component-Security.md | 10 ++++++++++ src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json | 9 +++++++++ 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docker/central-node-a/appsettings.Central.json b/docker/central-node-a/appsettings.Central.json index 8908a4d2..0ec94080 100644 --- a/docker/central-node-a/appsettings.Central.json +++ b/docker/central-node-a/appsettings.Central.json @@ -31,6 +31,10 @@ "ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local", "ServiceAccountPassword": "serviceaccount123" }, + "Auth": { + "DisableLogin": false, + "User": "multi-role" + }, "JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtExpiryMinutes": 15, "IdleTimeoutMinutes": 30, diff --git a/docker/central-node-b/appsettings.Central.json b/docker/central-node-b/appsettings.Central.json index 20884f6d..a83e26f2 100644 --- a/docker/central-node-b/appsettings.Central.json +++ b/docker/central-node-b/appsettings.Central.json @@ -31,6 +31,10 @@ "ServiceAccountDn": "cn=serviceaccount,dc=zb,dc=local", "ServiceAccountPassword": "serviceaccount123" }, + "Auth": { + "DisableLogin": false, + "User": "multi-role" + }, "JwtSigningKey": "scadabridge-dev-jwt-signing-key-must-be-at-least-32-characters-long", "JwtExpiryMinutes": 15, "IdleTimeoutMinutes": 30, diff --git a/docs/plans/2026-06-16-disable-login.md.tasks.json b/docs/plans/2026-06-16-disable-login.md.tasks.json index 03f87887..353f607a 100644 --- a/docs/plans/2026-06-16-disable-login.md.tasks.json +++ b/docs/plans/2026-06-16-disable-login.md.tasks.json @@ -5,7 +5,7 @@ "tasks": [ {"id": 62, "ref": "DL-1", "subject": "AuthDisableLoginOptions + Roles.All", "class": "small", "status": "completed", "commits": ["72691e5"]}, {"id": 63, "ref": "DL-2", "subject": "AutoLoginAuthenticationHandler + tests", "class": "high-risk", "status": "completed", "blockedBy": [62], "commits": ["dcd445a", "0926ce4"]}, - {"id": 64, "ref": "DL-3", "subject": "Wire flag into AddSecurity + Host + startup warning", "class": "standard", "status": "completed", "blockedBy": [62, 63]}, + {"id": 64, "ref": "DL-3", "subject": "Wire flag into AddSecurity + Host + startup warning", "class": "standard", "status": "completed", "blockedBy": [62, 63], "commits": ["e896042", "75919ce"]}, {"id": 65, "ref": "DL-4", "subject": "Docs + dev config note", "class": "trivial", "status": "pending", "blockedBy": [64]} ], "lastUpdated": "2026-06-16" diff --git a/docs/requirements/Component-Security.md b/docs/requirements/Component-Security.md index c4e844e4..d97ebdbc 100644 --- a/docs/requirements/Component-Security.md +++ b/docs/requirements/Component-Security.md @@ -22,6 +22,16 @@ Central cluster. Sites do not have user-facing interfaces and do not perform ind - **No local user store**: All identity and group information comes from AD. No credentials are cached locally. - **No Windows Integrated Authentication**: The app authenticates directly against LDAP/AD, not via Kerberos/NTLM. +## Dev Disable-Login Flag + +**`ScadaBridge:Security:Auth:DisableLogin`** (bool, default `false`) — when `true`, the Central UI bypasses the login form entirely and auto-authenticates every request as the user named by `ScadaBridge:Security:Auth:User` (default `multi-role`) with all four roles (Administrator, Designer, Deployer, Viewer) granted system-wide. The mechanism is `AutoLoginAuthenticationHandler`, registered under the cookie scheme via `AddSecurity(disableLogin: true)`; because it sits in the cookie scheme, every existing authorization policy authenticates through it with zero policy changes required. + +There is **no environment guard** — a loud startup warning in the application log is the only protection. This disables authentication on a SCADA control surface. + +> **Dev/test ONLY. Never enable in production.** + +Set in a local or docker-dev environment via the environment variable `ScadaBridge__Security__Auth__DisableLogin=true`. Note that `ScadaBridge:Security:Auth` is a child sub-section nested inside `ScadaBridge:Security`. + ## Session Management ### Cookie + JWT Hybrid diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json index a0879e67..39500a48 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json +++ b/src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json @@ -1,5 +1,14 @@ { "_logging": "Host-021: Serilog is the sole logger provider (Program.cs calls builder.Host.UseSerilog()), so the standard Microsoft 'Logging:LogLevel' block has no effect and was removed. The minimum level is set via 'ScadaBridge:Logging:MinimumLevel' (bound to LoggingOptions per Host-011); sinks are defined under the 'Serilog' section below and applied via ReadFrom.Configuration (Host-014). See LoggerConfigurationFactory + Component-Host.md REQ-HOST-8.", + "ScadaBridge": { + "Security": { + "Auth": { + "_comment": "DisableLogin bypasses the login form and auto-authenticates every request as User with all roles. DEV/TEST ONLY — no environment guard; a startup warning is the only protection. Never enable in production.", + "DisableLogin": false, + "User": "multi-role" + } + } + }, "Serilog": { "Using": [ "Serilog.Sinks.Console", From 13cd53ad1c689af40cf08d0b155f15c77915d9cf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:55:09 -0400 Subject: [PATCH 9/9] docs(plan): mark disable-login DL-1..DL-4 complete --- docs/plans/2026-06-16-disable-login.md.tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-06-16-disable-login.md.tasks.json b/docs/plans/2026-06-16-disable-login.md.tasks.json index 353f607a..18bd6b6a 100644 --- a/docs/plans/2026-06-16-disable-login.md.tasks.json +++ b/docs/plans/2026-06-16-disable-login.md.tasks.json @@ -6,7 +6,7 @@ {"id": 62, "ref": "DL-1", "subject": "AuthDisableLoginOptions + Roles.All", "class": "small", "status": "completed", "commits": ["72691e5"]}, {"id": 63, "ref": "DL-2", "subject": "AutoLoginAuthenticationHandler + tests", "class": "high-risk", "status": "completed", "blockedBy": [62], "commits": ["dcd445a", "0926ce4"]}, {"id": 64, "ref": "DL-3", "subject": "Wire flag into AddSecurity + Host + startup warning", "class": "standard", "status": "completed", "blockedBy": [62, 63], "commits": ["e896042", "75919ce"]}, - {"id": 65, "ref": "DL-4", "subject": "Docs + dev config note", "class": "trivial", "status": "pending", "blockedBy": [64]} + {"id": 65, "ref": "DL-4", "subject": "Docs + dev config note", "class": "trivial", "status": "completed", "blockedBy": [64], "commits": ["5730250"]} ], "lastUpdated": "2026-06-16" }