Faithful copy (warning only, no env guard); custom AuthenticationHandler under the cookie scheme; reuses M2.19 SessionClaimBuilder for an all-roles system-wide principal.
8.7 KiB
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 loudLogWarningon 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
mainfirst (b2d8fd8); this feature is built onfeature/disable-loginoff that updated main, so it can reuse M2.19'sSessionClaimBuilder. - Mechanism: a custom ASP.NET Core
AuthenticationHandlerregistered under the cookie scheme name when the flag is set (OtOpcUa's exact mechanism) — not request-middleware, not a BlazorAuthenticationStateProvideroverride.
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 toAuthDisableLoginOptions(SectionName = "Security:Auth"). AutoLoginAuthenticationHandler—HandleAuthenticateAsyncmints aClaimsPrincipalwithName/Username/DisplayName+ oneRoleclaim perDevAuthRoles.All(all enum role names"Operator"), returnsAuthenticateResult.Success. No cookie written — every request re-authenticates fresh.SignInAsync/SignOutAsyncare explicit no-ops (required, because/auth/logoutcallsHttpContext.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<AuthenticationSchemeOptions, AutoLoginAuthenticationHandler>(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<ILoggerFactory>emits one loudLogWarningnaming 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:
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:
public static readonly string[] All = [Administrator, Designer, Deployer, Viewer];
3. Auto-login handler
AutoLoginAuthenticationHandler in ZB.MOM.WW.ScadaBridge.Security, deriving from
SignInAuthenticationHandler<AuthenticationSchemeOptions> (so it satisfies the sign-in/sign-out
handler contract). It reuses M2.19's SessionClaimBuilder.Build(...) for claim parity with a
real login:
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.19OnValidatePrincipal. - flag true:
AddAuthentication(Cookies).AddScheme<AuthenticationSchemeOptions, AutoLoginAuthenticationHandler>(Cookies, _ => {})instead ofAddCookie, and emit the loud startupLogWarning.
Program.cs (Host) reads ScadaBridge:Security:Auth:DisableLogin from builder.Configuration
and passes it to AddSecurity(...). AddOptions<AuthDisableLoginOptions>().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
HandleAuthenticateAsyncalways returns success — it cannot fail (no I/O, no LDAP, no DB).- No-op sign-in/out cannot throw.
- If
Useris blank, fall back to"multi-role".
Testing
xUnit, in ZB.MOM.WW.ScadaBridge.Security.Tests (mirroring the existing harness):
- Handler —
HandleAuthenticateAsyncreturns an authenticated principal whose identity name is the configured user, carries all fourRoles.Allrole claims, and has noScopeIdclaim (system-wide). A blank/whitespaceUserfalls back tomulti-role. - No-op sign-in/out —
SignInAsync/SignOutAsynccomplete without throwing. - 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 resolvedIAuthenticationSchemeProvider/ schemeHandlerType). - (Optional) Claim parity — the handler's principal matches a real-login principal shape for
an all-roles system-wide user (reuse the M2.19
SessionClaimBuilderParityTestsidiom).
Files (anticipated)
- New:
src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs,.../Auth/AutoLoginAuthenticationHandler.cs. - Modify:
src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs(Allarray),.../ServiceCollectionExtensions.cs(flag param + scheme switch + warning + options bind),src/ZB.MOM.WW.ScadaBridge.Host/Program.cs(read flag, pass toAddSecurity). - 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.