20 KiB
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/<Project>/<Project>.csproj), run only dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter <Name>. 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<AuthenticationSchemeOptions>, IAuthenticationSignInHandler; ctor (IOptionsMonitor<AuthenticationSchemeOptions>, ILoggerFactory, UrlEncoder, IOptions<AuthDisableLoginOptions>); 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(addAllarray after line 40) - Test:
tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs(new)
Step 1: Create the options class
// src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs
namespace ZB.MOM.WW.ScadaBridge.Security.Auth;
/// <summary>
/// Dev/test flag: when <see cref="DisableLogin"/> is true the Central UI bypasses the login
/// form entirely and auto-authenticates EVERY request as <see cref="User"/> with ALL roles,
/// system-wide. Default OFF. This disables authentication on a SCADA control surface —
/// dev/test ONLY; never enable in production.
/// </summary>
public sealed class AuthDisableLoginOptions
{
/// <summary>Configuration section name (<c>ScadaBridge:Security:Auth</c>).</summary>
public const string SectionName = "ScadaBridge:Security:Auth";
/// <summary>When true, disable login and auto-authenticate every request. Default false.</summary>
public bool DisableLogin { get; set; }
/// <summary>The username the auto-login principal is minted with. Default "multi-role".</summary>
public string User { get; set; } = "multi-role";
}
Step 2: Add Roles.All — in Roles.cs, after the Viewer const (line 40):
/// <summary>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.</summary>
public static readonly string[] All = [Administrator, Designer, Deployer, Viewer];
Step 3: Write + run the guard test
// 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:
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):
// 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;
/// <summary>
/// Auth handler used ONLY when <see cref="AuthDisableLoginOptions.DisableLogin"/> is true.
/// Registered under the cookie scheme name, it authenticates EVERY request as the configured
/// dev user with all <see cref="Roles.All"/> roles, system-wide — no credential check, no cookie.
/// The minted principal mirrors a real login (it reuses <see cref="SessionClaimBuilder"/>).
/// Dev/test ONLY.
/// </summary>
public sealed class AutoLoginAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>, IAuthenticationSignInHandler
{
private readonly AuthDisableLoginOptions _opts;
private readonly TimeProvider _clock;
/// <summary>Initializes the handler with the scheme plumbing, the disable-login options, and the clock.</summary>
public AutoLoginAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IOptions<AuthDisableLoginOptions> disableLoginOptions,
TimeProvider clock)
: base(options, logger, encoder)
{
_opts = disableLoginOptions.Value;
_clock = clock;
}
/// <summary>No-op: auto-login writes no cookie, so an explicit sign-in has nothing to persist.</summary>
public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) => Task.CompletedTask;
/// <summary>No-op: there is no auth cookie to clear; the next request re-authenticates via this handler.</summary>
public Task SignOutAsync(AuthenticationProperties? properties) => Task.CompletedTask;
/// <inheritdoc />
protected override Task<AuthenticateResult> 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):
// 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<AutoLoginAuthenticationHandler> CreateAsync(string user = "multi-role")
{
var schemeOptions = Substitute.For<IOptionsMonitor<AuthenticationSchemeOptions>>();
schemeOptions.Get(Arg.Any<string>()).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:
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(theAddSecuritysignature ~line 40 + theAddAuthentication(...).AddCookie(...)block ~line 107) - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Program.cs(~line 124, theAddSecurity()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:
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:
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<AuthenticationSchemeOptions, Auth.AutoLoginAuthenticationHandler>(
CookieAuthenticationDefaults.AuthenticationScheme, _ => { });
// Loud, once-at-first-resolve warning (mirrors OtOpcUa).
services.AddOptions<Auth.AuthDisableLoginOptions>()
.PostConfigure<ILoggerFactory>((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<CookieAuthenticationOptions>(...) 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:
// 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<ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions>()
.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<bool>(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:
// 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<AuthenticationScheme?> ResolveCookieSchemeAsync(bool disableLogin)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSecurity(disableLogin);
await using var sp = services.BuildServiceProvider();
var provider = sp.GetRequiredService<IAuthenticationSchemeProvider>();
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:
AddSecuritymay resolve other dependencies; ifBuildServiceProviderthrows because a collaborator needs more registrations, register the minimal extras the test needs (or assert against theAuthenticationSchemeOptions/scheme map another way). Keep the test focused on the scheme→handler-type switch. If fullAddSecurityresolution 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:
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.jsonand-b, ORsrc/ZB.MOM.WW.ScadaBridge.Host/appsettings.json) — add theScadaBridge:Security:Auth:DisableLogin: falsekey as discoverable documentation (shipped false).
Step 1: In Component-Security.md, add:
Dev disable-login flag (
ScadaBridge:Security:Auth:DisableLogin) — whentrue, the Central UI bypasses login and auto-authenticates every request asSecurity:Auth:User(defaultmulti-role) with all roles, system-wide, viaAutoLoginAuthenticationHandlerregistered under the cookie scheme. Defaultfalse. No environment guard — a loud startup warning is the only protection. Dev/test ONLY; never enable in production. Set via env varScadaBridge__Security__Auth__DisableLogin=truein local/docker-dev.
Step 2: Add the key (value false) to the chosen appsettings file(s) so it is discoverable.
Step 3: Commit
git add docs/requirements/Component-Security.md <appsettings files touched>
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.