# Dashboard "Disable Login" Dev Flag — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Add a `MxGateway:Dashboard:DisableLogin` config flag that, when on, auto-authenticates every dashboard request as a fixed dev user (default `multi-role`) holding both dashboard roles — no login form, cookie, or LDAP bind. **Architecture:** When the flag is on, the dashboard's `AddCookie(...)` registration is replaced by a custom `AuthenticationHandler` registered **under the same scheme name** (`MxGateway.Dashboard`) whose `HandleAuthenticateAsync` always succeeds with a multi-role principal. `UseAuthentication()` stamps that principal on `HttpContext.User` for every request, so every policy (Viewer/Admin/HubClients), the Blazor circuit, and the SignalR hubs see a signed-in admin with **zero policy or page changes**. Mirrors the sister project OtOpcUa's `Security:Auth:DisableLogin`. **Tech Stack:** .NET 10 (x64) gateway server; ASP.NET Core authentication/authorization; xUnit. Server-side only — no worker, no `.proto`, no clients, no gRPC API-key changes. Builds and tests entirely on macOS. **Design doc:** `docs/plans/2026-06-16-dashboard-disable-login-design.md` **Key existing files (verified):** - `src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs` — options bound from `MxGateway:Dashboard`. - `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs::AddGatewayDashboard` — auth scheme + policy wiring. - `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs` — scheme/policy name constants (`AuthenticationScheme = "MxGateway.Dashboard"`, `AdminPolicy`, `ViewerPolicy`). - `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardRoles.cs` — `Admin = "Administrator"`, `Viewer = "Viewer"`. - `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs::CreatePrincipal` — the claim shape to mirror (`ZbClaimTypes.Name/Username/DisplayName` + `ZbClaimTypes.Role` per role; identity authType = scheme, nameType = `ZbClaimTypes.Name`, roleType = `ZbClaimTypes.Role`). - `ZbClaimTypes` (from `ZB.MOM.WW.Auth.AspNetCore`): `Name` (= `ClaimTypes.Name`), `Role` (= `ClaimTypes.Role`), `Username` (`"zb:username"`), `DisplayName` (`"zb:displayname"`). - `src/ZB.MOM.WW.MxGateway.Server/Properties/AssemblyInfo.cs` — `InternalsVisibleTo("ZB.MOM.WW.MxGateway.Tests")` (so `internal` members are test-visible). **Test conventions (verified):** no Moq/NSubstitute — hand-written stubs only. Integration-style tests build the real app with `GatewayApplication.Build(["--MxGateway:Dashboard:Key=value"])` and resolve services from `app.Services` (see `DashboardCookieOptionsTests`, `DashboardHubsRegistrationTests`). Run filtered tests only (per standing guidance), with `MSBUILDDISABLENODEREUSE=1`. --- ### Task 1: Config fields on `DashboardOptions` **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** none (Tasks 2/3 depend on these fields) **Files:** - Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs` - Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs` **Step 1: Write the failing test** — add to `GatewayOptionsTests.cs`: ```csharp [Fact] public void DashboardOptions_DisableLogin_DefaultsToFalse() { Assert.False(new DashboardOptions().DisableLogin); } [Fact] public void DashboardOptions_AutoLoginUser_DefaultsToNull() { Assert.Null(new DashboardOptions().AutoLoginUser); } ``` (If `GatewayOptionsTests` lacks `using ZB.MOM.WW.MxGateway.Server.Configuration;`, add it.) **Step 2: Run it, expect FAIL** (compile error: no such members) Run: `MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GatewayOptionsTests.DashboardOptions"` **Step 3: Add the two `init` properties** to `DashboardOptions.cs` (place near `AllowAnonymousLocalhost`): ```csharp /// /// DEV/TEST ONLY. When true, the dashboard bypasses the login form entirely and /// auto-authenticates EVERY request as holding both /// dashboard roles (Administrator + Viewer). No cookie, no LDAP bind. Default false. /// Unlike (which only succeeds the authorization /// requirement without authenticating), this mints a real principal, so the UI behaves /// as a signed-in admin and applies to all clients (not just loopback). Never enable in /// production. See docs/plans/2026-06-16-dashboard-disable-login-design.md. /// public bool DisableLogin { get; init; } /// /// Username minted for the auto-login principal when is true. /// Null/blank falls back to the GLAuth Administrator test user multi-role. /// public string? AutoLoginUser { get; init; } ``` **Step 4: Run the test, expect PASS.** **Step 5: Commit** ```bash git add src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs git commit -m "feat(dashboard): add DisableLogin + AutoLoginUser options (default off)" ``` --- ### Task 2: `DashboardAutoLoginAuthenticationHandler` + unit tests **Classification:** high-risk (security/auth code) **Estimated implement time:** ~5 min **Parallelizable with:** Task 4 **Files:** - Create: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAutoLoginAuthenticationHandler.cs` - Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAutoLoginAuthenticationHandlerTests.cs` **Step 1: Write the failing test** (`DashboardAutoLoginAuthenticationHandlerTests.cs`): ```csharp using System.Security.Claims; using ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardAutoLoginAuthenticationHandlerTests { [Fact] public void CreatePrincipal_MintsAuthenticatedMultiRoleUser() { ClaimsPrincipal principal = DashboardAutoLoginAuthenticationHandler.CreatePrincipal("multi-role"); Assert.True(principal.Identity!.IsAuthenticated); Assert.Equal("multi-role", principal.Identity!.Name); Assert.True(principal.IsInRole(DashboardRoles.Admin)); Assert.True(principal.IsInRole(DashboardRoles.Viewer)); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void CreatePrincipal_BlankUser_FallsBackToDefault(string? user) { ClaimsPrincipal principal = DashboardAutoLoginAuthenticationHandler.CreatePrincipal(user); Assert.Equal(DashboardAutoLoginAuthenticationHandler.DefaultUser, principal.Identity!.Name); } [Fact] public void CreatePrincipal_TrimsUser() { ClaimsPrincipal principal = DashboardAutoLoginAuthenticationHandler.CreatePrincipal(" multi-role "); Assert.Equal("multi-role", principal.Identity!.Name); } } ``` **Step 2: Run it, expect FAIL** (type does not exist). Run: `MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~DashboardAutoLoginAuthenticationHandlerTests"` **Step 3: Implement** `DashboardAutoLoginAuthenticationHandler.cs`: ```csharp using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; /// /// Authentication handler used ONLY when MxGateway:Dashboard:DisableLogin is true. /// Registered under the dashboard cookie scheme name /// (), it authenticates /// EVERY request as the configured dev user with both dashboard roles — no credential check, /// no cookie, no LDAP bind. The minted principal mirrors the shape the real login /// () produces, so policies and the UI cannot tell it /// apart. DEV/TEST ONLY; never enable in production. /// public sealed class DashboardAutoLoginAuthenticationHandler : AuthenticationHandler, IAuthenticationSignInHandler { /// Username used when AutoLoginUser is null or blank. public const string DefaultUser = "multi-role"; private readonly string _user; /// Initializes the handler with scheme plumbing and the dashboard options. /// The per-scheme authentication options monitor. /// The logger factory the base handler uses. /// The URL encoder the base handler uses. /// Gateway options carrying the dashboard auto-login user. public DashboardAutoLoginAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IOptions gatewayOptions) : base(options, logger, encoder) => _user = gatewayOptions.Value.Dashboard.AutoLoginUser ?? DefaultUser; /// No-op: auto-login writes no cookie, so a sign-in has nothing to persist. /// Ignored. /// Ignored. /// A completed task. public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) => Task.CompletedTask; /// No-op: there is no auth cookie to clear; the next request re-authenticates. /// Ignored. /// A completed task. public Task SignOutAsync(AuthenticationProperties? properties) => Task.CompletedTask; /// protected override Task HandleAuthenticateAsync() { ClaimsPrincipal principal = CreatePrincipal(_user); AuthenticationTicket ticket = new(principal, Scheme.Name); return Task.FromResult(AuthenticateResult.Success(ticket)); } /// /// Builds the multi-role dev principal. Null/blank falls back to /// . Claim shape mirrors . /// /// The configured auto-login username (may be null/blank). /// An authenticated principal holding both dashboard roles. internal static ClaimsPrincipal CreatePrincipal(string? user) { string name = string.IsNullOrWhiteSpace(user) ? DefaultUser : user.Trim(); Claim[] claims = [ new Claim(ClaimTypes.NameIdentifier, name), new Claim(ZbClaimTypes.Username, name), new Claim(ZbClaimTypes.Name, name), new Claim(ZbClaimTypes.DisplayName, name), new Claim(ZbClaimTypes.Role, DashboardRoles.Admin), new Claim(ZbClaimTypes.Role, DashboardRoles.Viewer), ]; ClaimsIdentity identity = new( claims, DashboardAuthenticationDefaults.AuthenticationScheme, ZbClaimTypes.Name, ZbClaimTypes.Role); return new ClaimsPrincipal(identity); } } ``` **Step 4: Run the test, expect PASS.** **Step 5: Commit** ```bash git add src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAutoLoginAuthenticationHandler.cs src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAutoLoginAuthenticationHandlerTests.cs git commit -m "feat(dashboard): add auto-login auth handler for DisableLogin mode" ``` --- ### Task 3: Wire the scheme swap + startup warning + wiring/authorization tests **Classification:** high-risk (security wiring) **Estimated implement time:** ~5 min **Parallelizable with:** none (depends on Task 2's handler) **Files:** - Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs` - Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardDisableLoginTests.cs` (create) **Step 1: Write the failing tests** (`DashboardDisableLoginTests.cs`): ```csharp using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.MxGateway.Server; using ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardDisableLoginTests { [Fact] public async Task DisableLoginOff_CookieSchemeUsesCookieHandler() { await using WebApplication app = GatewayApplication.Build([]); IAuthenticationSchemeProvider provider = app.Services.GetRequiredService(); AuthenticationScheme? scheme = await provider.GetSchemeAsync( DashboardAuthenticationDefaults.AuthenticationScheme); Assert.NotNull(scheme); Assert.Equal(typeof(CookieAuthenticationHandler), scheme!.HandlerType); } [Fact] public async Task DisableLoginOn_CookieSchemeUsesAutoLoginHandler() { await using WebApplication app = GatewayApplication.Build( ["--MxGateway:Dashboard:DisableLogin=true"]); IAuthenticationSchemeProvider provider = app.Services.GetRequiredService(); AuthenticationScheme? scheme = await provider.GetSchemeAsync( DashboardAuthenticationDefaults.AuthenticationScheme); Assert.NotNull(scheme); Assert.Equal(typeof(DashboardAutoLoginAuthenticationHandler), scheme!.HandlerType); } [Fact] public async Task DisableLoginOn_AutoLoginPrincipalSatisfiesAdminAndViewerPolicies() { await using WebApplication app = GatewayApplication.Build( ["--MxGateway:Dashboard:DisableLogin=true"]); IAuthorizationService authorization = app.Services.GetRequiredService(); ClaimsPrincipal user = DashboardAutoLoginAuthenticationHandler.CreatePrincipal("multi-role"); Assert.True((await authorization.AuthorizeAsync( user, resource: null, DashboardAuthenticationDefaults.AdminPolicy)).Succeeded); Assert.True((await authorization.AuthorizeAsync( user, resource: null, DashboardAuthenticationDefaults.ViewerPolicy)).Succeeded); } } ``` > Note: `AuthorizeAsync` invokes the real `DashboardAuthorizationHandler` against the minted > principal — its role-check branch succeeds independent of `HttpContext` (loopback check > returns false with no request, and `Authentication.Mode` defaults to `ApiKey`), so this > proves the policies pass purely on the minted roles. **Step 2: Run them, expect FAIL** (the `DisableLoginOn_*` tests fail — handler not yet wired; cookie handler still registered). Run: `MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~DashboardDisableLoginTests"` **Step 3: Rewire `AddGatewayDashboard`.** In `DashboardServiceCollectionExtensions.cs`, replace the current authentication-builder block: ```csharp services .AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme) .AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions => { // ... existing cookie config ... }) .AddScheme( DashboardAuthenticationDefaults.HubAuthenticationScheme, _ => { }); ``` with: ```csharp // DEV/TEST ONLY. Read directly from configuration here because authentication scheme // registration runs before options binding. Key mirrors DashboardOptions.DisableLogin. bool disableLogin = configuration.GetValue("MxGateway:Dashboard:DisableLogin"); AuthenticationBuilder authentication = services.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme); if (disableLogin) { // Register an always-authenticating handler UNDER the cookie scheme name, so the // Viewer/Admin/HubClients policies (which all resolve this scheme) authenticate // through it as the multi-role dev user — zero policy or page changes. authentication.AddScheme( DashboardAuthenticationDefaults.AuthenticationScheme, _ => { }); // Loud, once-at-startup warning (emitted when GatewayOptions is first resolved). services.AddOptions().PostConfigure((gatewayOptions, loggerFactory) => loggerFactory .CreateLogger("ZB.MOM.WW.MxGateway.Server.Dashboard.DisableLogin") .LogWarning( "DASHBOARD LOGIN DISABLED (MxGateway:Dashboard:DisableLogin=true) — every request is " + "authenticated as '{User}' with full permissions ({Roles}). Dev/test only; never " + "enable in production.", gatewayOptions.Dashboard.AutoLoginUser ?? DashboardAutoLoginAuthenticationHandler.DefaultUser, $"{DashboardRoles.Admin}, {DashboardRoles.Viewer}")); } else { authentication.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions => { // ... MOVE the existing cookie config body here unchanged ... }); } authentication.AddScheme( DashboardAuthenticationDefaults.HubAuthenticationScheme, _ => { }); ``` Notes for the implementer: - Keep the existing `services.AddOptions(scheme).Configure(...)` block (RequireHttpsCookie / cookie-name) as-is. When `disableLogin` is on it configures an options object no handler reads — harmless dead config; not worth guarding. - Required usings should already be present (`Microsoft.AspNetCore.Authentication`, `Microsoft.Extensions.Configuration`, `Microsoft.Extensions.Logging`, the `Configuration` namespace for `GatewayOptions`). Add any that are missing. - `configuration.GetValue` defaults to `false` when the key is absent — preserves default-off. **Step 4: Run the tests, expect PASS** (all three). **Step 5: Run the broader dashboard auth tests to confirm no regression:** Run: `MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~Dashboard"` Expected: all pass (existing `DashboardCookieOptionsTests`, `DashboardHubsRegistrationTests`, etc., still green — they build with the flag off). > The startup warning is verified by inspection / manual run (`dotnet run … --MxGateway:Dashboard:DisableLogin=true` logs the warning once). It is not asserted automatically — capturing a startup log line would require injecting a log provider the `Build` harness does not expose, and the warning is a safety nicety, not core behavior. **Step 6: Commit** ```bash git add src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardDisableLoginTests.cs git commit -m "feat(dashboard): swap to auto-login handler when DisableLogin is set" ``` --- ### Task 4: Documentation **Classification:** small **Estimated implement time:** ~3 min **Parallelizable with:** Task 2 (disjoint files — docs vs src/test) **Files:** - Modify: `docs/GatewayConfiguration.md` - Modify: `docs/GatewayDashboardDesign.md` - Modify: `CLAUDE.md` **Step 1:** In `docs/GatewayConfiguration.md`, add `MxGateway:Dashboard:DisableLogin` (bool, default `false`) and `MxGateway:Dashboard:AutoLoginUser` (string, default `multi-role`) to the dashboard options section. Describe: dev/test only; auto-authenticates every request as `AutoLoginUser` with both roles; applies to all clients (not just loopback); never enable in production. Note it differs from `AllowAnonymousLocalhost` (which only bypasses authorization without minting a principal). **Step 2:** In `docs/GatewayDashboardDesign.md`, document the auth-scheme swap: when the flag is on, the cookie handler is replaced by `DashboardAutoLoginAuthenticationHandler` under the same scheme name; explain *why* (every policy resolves that scheme, so no policy/page changes), and that it is dev/test only with a loud startup warning. **Step 3:** In `CLAUDE.md`, in the Authentication section near the `Dashboard:AllowAnonymousLocalhost` sentence, add one sentence: `MxGateway:Dashboard:DisableLogin` (default off) auto-authenticates every dashboard request as `AutoLoginUser` (default `multi-role`) with all roles — dev/test only. **Step 4: Commit** ```bash git add docs/GatewayConfiguration.md docs/GatewayDashboardDesign.md CLAUDE.md git commit -m "docs: document dashboard DisableLogin / AutoLoginUser dev flag" ``` --- ## Verification (after all tasks) ```bash MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj \ --filter "FullyQualifiedName~Dashboard|FullyQualifiedName~GatewayOptions" ``` Expected: all dashboard + options tests pass. (Known macOS-only failures `OrphanWorkerTerminatorTests` ×2 and the parallel-load `SqliteAuthStoreTests` TLS temp-file test are unrelated and out of this filter.) Then `superpowers-extended-cc:finishing-a-development-branch` to merge/push.