From 74b9218a92d4bb5c38fac1d90697fa58d65ba32a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 29 May 2026 07:47:58 -0400 Subject: [PATCH] refactor(security): drop JwtBearer parallel scheme, externalize cookie config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single Cookie auth scheme; framework default challenge restores 302 → /login for browsers + 401 for AJAX. OtOpcUaCookieOptions now flows through to CookieAuthenticationOptions via PostConfigure (fixes a latent bug where the options class was bound but ignored). Cookie name moves to ZB.MOM.WW.OtOpcUa.Auth; existing sessions get a one-time forced sign-out. --- .../ServiceCollectionExtensions.cs | 83 +++++++++---------- .../AuthEndpointsIntegrationTests.cs | 2 +- 2 files changed, 39 insertions(+), 46 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 8fc6903a..864307a2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -1,9 +1,9 @@ using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Security.Jwt; @@ -12,35 +12,20 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap; namespace ZB.MOM.WW.OtOpcUa.Security; /// -/// Resolves from the real DI container at runtime so the bearer -/// pipeline's stay in -/// lock-step with . Replaces the prior -/// services.BuildServiceProvider() antipattern (ASP0000) that built a captive provider -/// from inside .AddJwtBearer. +/// DI registration for OtOpcUa auth. Single Cookie scheme (the JWT lives inside the +/// cookie as its credential payload); no JwtBearer parallel scheme. Matches ScadaBridge +/// structurally — see docs/plans/2026-05-29-auth-alignment-design.md. /// -internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService) - : IPostConfigureOptions -{ - /// Configures JWT bearer options from the token service. - /// The options name. - /// The JWT bearer options to configure. - public void PostConfigure(string? name, JwtBearerOptions options) - { - if (name != JwtBearerDefaults.AuthenticationScheme) return; - options.TokenValidationParameters = tokenService.BuildValidationParameters(); - } -} - public static class ServiceCollectionExtensions { /// - /// Wires cookie+JWT hybrid authentication. Cookies are the primary scheme for browser-facing - /// Blazor + Razor flows; JWT bearer is layered in for external API consumers (OPC UA client - /// tools, scripts). DataProtection keys persist to the shared ConfigDb so cookies survive - /// failover between nodes. + /// Wires cookie authentication, DataProtection key persistence to ConfigDb, + /// LDAP services, and the LDAP-backed JwtTokenService. Browser flows redirect to + /// /login; AJAX/JSON callers receive 401 (handled by the framework's default + /// challenge heuristic). /// /// The service collection. - /// The application configuration. + /// The application configuration root. public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration) { services.AddOptions().Bind(configuration.GetSection(JwtOptions.SectionName)); @@ -50,8 +35,6 @@ public static class ServiceCollectionExtensions services.AddSingleton(); // Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and // must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes. - // The driver-branch in Host/Program.cs registers the same way; consistent lifetime - // across both paths keeps ValidateScopes-on-Build clean. services.AddSingleton(); services.AddDataProtection() @@ -61,32 +44,42 @@ public static class ServiceCollectionExtensions services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(o => { - o.Cookie.Name = "OtOpcUa.Auth"; + // Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration + // are bound from OtOpcUaCookieOptions in the PostConfigure block below. + o.LoginPath = "/login"; + o.LogoutPath = "/auth/logout"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict; - o.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; - o.SlidingExpiration = true; - o.ExpireTimeSpan = TimeSpan.FromMinutes(30); - o.Events.OnRedirectToLogin = ctx => - { - ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; - return Task.CompletedTask; - }; - o.Events.OnRedirectToAccessDenied = ctx => - { - ctx.Response.StatusCode = StatusCodes.Status403Forbidden; - return Task.CompletedTask; - }; - }) - .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { /* parameters set by IPostConfigureOptions below */ }); + // No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's + // built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX). + }); - services.AddSingleton, ConfigureJwtBearerFromTokenService>(); + // Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a + // pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored. + services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme) + .Configure, ILoggerFactory>((cookieOpts, ourOpts, lf) => + { + var v = ourOpts.Value; + cookieOpts.Cookie.Name = v.Name; + cookieOpts.ExpireTimeSpan = TimeSpan.FromMinutes(v.ExpiryMinutes); + cookieOpts.SlidingExpiration = true; + cookieOpts.Cookie.SecurePolicy = v.RequireHttpsCookie + ? CookieSecurePolicy.Always + : CookieSecurePolicy.SameAsRequest; + + if (!v.RequireHttpsCookie) + { + lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning( + "Security:Cookie:RequireHttpsCookie is DISABLED — auth cookie SecurePolicy is " + + "SameAsRequest. The cookie-embedded JWT will travel in cleartext over plain HTTP. " + + "Intended for local dev only — set Security:Cookie:RequireHttpsCookie=true in production."); + } + }); services.AddAuthorization(o => { o.FallbackPolicy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder( - CookieAuthenticationDefaults.AuthenticationScheme, - JwtBearerDefaults.AuthenticationScheme) + CookieAuthenticationDefaults.AuthenticationScheme) .RequireAuthenticatedUser() .Build(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs index d5ec0112..6ab0018c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs @@ -90,7 +90,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct); response.StatusCode.ShouldBe(HttpStatusCode.NoContent); - response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("OtOpcUa.Auth=")); + response.Headers.GetValues("Set-Cookie").ShouldContain(c => c.StartsWith("ZB.MOM.WW.OtOpcUa.Auth=")); } /// Tests that login with invalid credentials returns 401.