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); + } +}