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.
This commit is contained in:
Joseph Doherty
2026-06-16 08:31:19 -04:00
parent b2d8fd8a0a
commit 5cf2d1cb99
@@ -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<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 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<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:
```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<AuthenticationSchemeOptions,
AutoLoginAuthenticationHandler>(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<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
- `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.