refactor(security): drop JwtBearer parallel scheme, externalize cookie config

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.
This commit is contained in:
Joseph Doherty
2026-05-29 07:47:58 -04:00
parent 532e9933f3
commit 74b9218a92
2 changed files with 39 additions and 46 deletions
@@ -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;
/// <summary>
/// Resolves <see cref="JwtTokenService"/> from the real DI container at runtime so the bearer
/// pipeline's <see cref="Microsoft.IdentityModel.Tokens.TokenValidationParameters"/> stay in
/// lock-step with <see cref="JwtTokenService.BuildValidationParameters"/>. Replaces the prior
/// <c>services.BuildServiceProvider()</c> antipattern (ASP0000) that built a captive provider
/// from inside <c>.AddJwtBearer</c>.
/// 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 <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
/// </summary>
internal sealed class ConfigureJwtBearerFromTokenService(JwtTokenService tokenService)
: IPostConfigureOptions<JwtBearerOptions>
{
/// <summary>Configures JWT bearer options from the token service.</summary>
/// <param name="name">The options name.</param>
/// <param name="options">The JWT bearer options to configure.</param>
public void PostConfigure(string? name, JwtBearerOptions options)
{
if (name != JwtBearerDefaults.AuthenticationScheme) return;
options.TokenValidationParameters = tokenService.BuildValidationParameters();
}
}
public static class ServiceCollectionExtensions
{
/// <summary>
/// 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
/// <c>/login</c>; AJAX/JSON callers receive 401 (handled by the framework's default
/// challenge heuristic).
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The application configuration.</param>
/// <param name="configuration">The application configuration root.</param>
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
@@ -50,8 +35,6 @@ public static class ServiceCollectionExtensions
services.AddSingleton<JwtTokenService>();
// 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<ILdapAuthService, LdapAuthService>();
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<IPostConfigureOptions<JwtBearerOptions>, ConfigureJwtBearerFromTokenService>();
// Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a
// pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored.
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<OtOpcUaCookieOptions>, 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();
@@ -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="));
}
/// <summary>Tests that login with invalid credentials returns 401.</summary>