From caeaae21f98558f71581059ebefe16f6fc74f96a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 04:31:07 -0400 Subject: [PATCH] feat(security): AutoLoginAuthenticationHandler for dev login bypass --- .../Auth/AutoLoginAuthenticationHandler.cs | 56 ++++++++++++++++ .../AutoLoginAuthenticationHandlerTests.cs | 64 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AutoLoginAuthenticationHandler.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AutoLoginAuthenticationHandlerTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AutoLoginAuthenticationHandler.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AutoLoginAuthenticationHandler.cs new file mode 100644 index 00000000..7e130725 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AutoLoginAuthenticationHandler.cs @@ -0,0 +1,56 @@ +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; +using ZB.MOM.WW.Auth.AspNetCore; // ZbClaimTypes — same source AuthEndpoints mints claims from. + +namespace ZB.MOM.WW.OtOpcUa.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 — no credential check, no cookie. +/// The minted principal mirrors the shape the real login (AuthEndpoints) produces. +/// +public sealed class AutoLoginAuthenticationHandler + : AuthenticationHandler +{ + private readonly AuthDisableLoginOptions _opts; + + /// Initializes the handler with the scheme plumbing and the disable-login options. + /// The per-scheme authentication options monitor. + /// The logger factory the base handler uses. + /// The URL encoder the base handler uses. + /// The disable-login dev flag and configured username. + public AutoLoginAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IOptions disableLoginOptions) + : base(options, logger, encoder) + => _opts = disableLoginOptions.Value; + + /// + protected override Task HandleAuthenticateAsync() + { + var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role-test" : _opts.User; + + var claims = new List + { + // ZbClaimTypes.Name = ClaimTypes.Name — populates Identity.Name canonically. + new(ZbClaimTypes.Name, user), + new(ZbClaimTypes.Username, user), + new(ZbClaimTypes.DisplayName, user), + }; + foreach (var role in DevAuthRoles.All) + // ZbClaimTypes.Role = ClaimTypes.Role — framework [Authorize(Roles=...)] + IsInRole work. + claims.Add(new Claim(ZbClaimTypes.Role, role)); + + var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AutoLoginAuthenticationHandlerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AutoLoginAuthenticationHandlerTests.cs new file mode 100644 index 00000000..145044ec --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AutoLoginAuthenticationHandlerTests.cs @@ -0,0 +1,64 @@ +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 Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Security.Auth; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests; + +public class AutoLoginAuthenticationHandlerTests +{ + private static async Task AuthenticateAsync(string user = "multi-role-test") + { + var schemeOpts = new StubOptionsMonitor(new AuthenticationSchemeOptions()); + var disableOpts = Options.Create(new AuthDisableLoginOptions { DisableLogin = true, User = user }); + + var handler = new AutoLoginAuthenticationHandler( + schemeOpts, NullLoggerFactory.Instance, UrlEncoder.Default, disableOpts); + await handler.InitializeAsync( + new AuthenticationScheme( + CookieAuthenticationDefaults.AuthenticationScheme, null, typeof(AutoLoginAuthenticationHandler)), + new DefaultHttpContext()); + return await handler.AuthenticateAsync(); + } + + [Fact] + public async Task Authenticates_as_configured_user_with_all_roles() + { + var result = await AuthenticateAsync(); + + result.Succeeded.ShouldBeTrue(); + result.Principal!.Identity!.IsAuthenticated.ShouldBeTrue(); + result.Principal.Identity.Name.ShouldBe("multi-role-test"); + foreach (var role in DevAuthRoles.All) + result.Principal.IsInRole(role).ShouldBeTrue(); + // Satisfies the FleetAdmin (Administrator) + DriverOperator (Operator|Administrator) policies. + result.Principal.IsInRole("Administrator").ShouldBeTrue(); + result.Principal.IsInRole("Operator").ShouldBeTrue(); + } + + [Fact] + public async Task Honours_configured_username() + { + var result = await AuthenticateAsync("custom-dev"); + result.Principal!.Identity!.Name.ShouldBe("custom-dev"); + } + + /// + /// Minimal stub returning a fixed value for any + /// name. The test project does not reference Moq, so the scheme-options monitor the base + /// needs is hand-rolled here. + /// + private sealed class StubOptionsMonitor(T value) : IOptionsMonitor + { + public T CurrentValue { get; } = value; + + public T Get(string? name) => CurrentValue; + + public IDisposable? OnChange(Action listener) => null; + } +}