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.