using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Security.Audit; using ZB.MOM.WW.OtOpcUa.Security.Auth; using ZB.MOM.WW.OtOpcUa.Security.Jwt; using ZB.MOM.WW.OtOpcUa.Security.Ldap; namespace ZB.MOM.WW.OtOpcUa.Security; /// /// DI registration for OtOpcUa auth. The cookie scheme carries the browser session; when /// Security:Jwt:SigningKey is configured a scheme /// accepts the tokens /auth/token mints (R03:2.4), and the default scheme is a policy scheme /// that forwards to whichever the request presents — so /api/* and every /// RequireAuthorization endpoint take a browser session OR a machine token. Matches /// ScadaBridge structurally — see docs/plans/2026-05-29-auth-alignment-design.md. /// public static class ServiceCollectionExtensions { /// /// 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 root. /// The same service collection, for chaining. public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration) { // JWT is opt-in by key: with no SigningKey the feature is off (no /auth/token, no bearer scheme) // and an admin node boots as before; with one, the key is validated at host START (R03:2.4) — // the JwtTokenService constructor used to throw on the first /auth/token call (HTTP 500). services.AddOptions() .Bind(configuration.GetSection(JwtOptions.SectionName)) .ValidateOnStart(); services.AddSingleton, JwtOptionsValidator>(); var jwtConfigured = configuration.GetSection(JwtOptions.SectionName).Get()?.IsConfigured ?? false; services.AddOptions().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName)); services.AddOptions().Bind(configuration.GetSection(LdapOptions.SectionName)); services.AddOptions().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName)); services.AddSingleton(); // IHttpContextAccessor is not registered by default — call AddHttpContextAccessor() // so HttpAuditActorAccessor and any Blazor/minimal-API component that reads the current // HTTP context by injection can resolve it. AddHttpContextAccessor is idempotent (internal // TryAdd), so calling it here is safe even if the host also calls it elsewhere. services.AddHttpContextAccessor(); // IAuditActorAccessor — resolves the authenticated HTTP principal's actor string for use // as the Actor field when constructing a canonical ZB.MOM.WW.Audit.AuditEvent. Registered // Scoped so it correctly follows the request scope used by Blazor Server and minimal-API // endpoints. // AuthenticationStateAuditActorAccessor reads the Blazor circuit's principal first (no HttpContext // exists on a circuit's threads after the first render) and falls back to the HTTP context for // minimal-API scopes, where AuthenticationStateProvider is not registered (R03:2.5). services.TryAddScoped(); // Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it // wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton // AdminUI login on admin-role nodes. This is the app's ILdapAuthService: it // adds the Enabled master switch + DevStubMode bypass on top of the shared ZB.MOM.WW.Auth.Ldap // service. TryAdd so a fused admin+driver node (which also registers it in Program.cs for the // driver path) ends up with exactly one descriptor regardless of registration order. services.TryAddSingleton(); // Shared ZB.MOM.WW.Auth group→role mapper seam. Wraps the existing RoleMapper.Map + // RoleMapper.Merge logic; consumed by AuthEndpoints.cs (/auth/login) and by // AdminUI login (the OPC UA data plane uses API keys). Scoped to match // ILdapGroupRoleMappingService (DbContext-backed, registered Scoped) — a singleton here // would capture the scoped DB service. services.TryAddScoped, OtOpcUaGroupRoleMapper>(); services.AddDataProtection() .PersistKeysToDbContext() .SetApplicationName("OtOpcUa"); var disableLogin = configuration .GetSection(AuthDisableLoginOptions.SectionName) .GetValue(nameof(AuthDisableLoginOptions.DisableLogin)); // The default scheme is the cookie-or-bearer selector; sign-in/out stay on the cookie scheme, which is // the only one that can issue a session. With JWT off the selector always picks the cookie. var authBuilder = services.AddAuthentication(o => { o.DefaultScheme = OtOpcUaAuthSchemes.CookieOrBearer; o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme; o.DefaultSignOutScheme = CookieAuthenticationDefaults.AuthenticationScheme; }); authBuilder.AddPolicyScheme(OtOpcUaAuthSchemes.CookieOrBearer, "Cookie or Bearer", o => { o.ForwardDefaultSelector = ctx => jwtConfigured && OtOpcUaAuthSchemes.HasBearerToken(ctx) ? OtOpcUaAuthSchemes.Bearer : CookieAuthenticationDefaults.AuthenticationScheme; }); if (jwtConfigured) { // First-party JwtBearer (R03:2.4, fix round 1 / I1) — the earlier hand-rolled handler is gone. // Validation parameters come from the SAME JwtTokenService that mints, so issuer/audience/key/ // claim types can never drift; BuildValidationParameters pins ValidAlgorithms = HS256 (an // alg:none or a different-HMAC token is refused). MapInboundClaims=false keeps the minted // "Role"/"zb:username" claim keys verbatim, which RoleClaimType/NameClaimType name. A bad token // is AuthenticateResult.Fail ⇒ 401 + WWW-Authenticate: Bearer, never a 500. authBuilder.AddJwtBearer(OtOpcUaAuthSchemes.Bearer, "OtOpcUa bearer token", _ => { }); services.AddOptions(OtOpcUaAuthSchemes.Bearer) .Configure((o, jwt) => { o.TokenValidationParameters = jwt.BuildValidationParameters(); o.MapInboundClaims = false; o.SaveToken = false; o.IncludeErrorDetails = false; }); } if (disableLogin) { // DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered // UNDER the cookie scheme name, so FallbackPolicy + FleetAdmin + DriverOperator (which all // name this scheme) authenticate through it and pass with all roles — zero policy changes. authBuilder.AddScheme( CookieAuthenticationDefaults.AuthenticationScheme, _ => { }); // Loud, once-at-first-resolve warning (mirrors the cookie RequireHttps warning idiom below). services.AddOptions() .PostConfigure((opts, lf) => { lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning( "AdminUI LOGIN DISABLED (Security:Auth:DisableLogin=true) — every request is " + "authenticated as '{User}' with FULL permissions ({Roles}). Dev/test only; never " + "enable in production.", opts.User, string.Join(",", DevAuthRoles.All)); }); } else { authBuilder.AddCookie(o => { // Static fields only — Name / ExpireTimeSpan / SecurePolicy / SlidingExpiration / // HttpOnly / SameSite are applied from OtOpcUaCookieOptions via ZbCookieDefaults // in the PostConfigure block below. o.LoginPath = "/login"; o.LogoutPath = "/auth/logout"; // No OnRedirectToLogin / OnRedirectToAccessDenied overrides — let the framework's // built-in IsAjaxRequest heuristic do its thing (302 for browsers, 401 for AJAX). }); } // Externalised cookie config — mirrors ScadaBridge's PostConfigure pattern. Fixes a // pre-existing latent bug where OtOpcUaCookieOptions was bound but ignored. // ZbCookieDefaults.Apply sets HttpOnly=true, SameSite=Strict, SlidingExpiration=true, // SecurePolicy, and ExpireTimeSpan; we then set the app-specific cookie name on top. services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme) .Configure, ILoggerFactory>((cookieOpts, ourOpts, lf) => { var v = ourOpts.Value; // Apply canonical hardened defaults (HttpOnly, SameSite=Strict, SlidingExpiration, // SecurePolicy, ExpireTimeSpan). Cookie name is NOT touched by ZbCookieDefaults — // we set it below so each app keeps its own distinct cookie name. ZbCookieDefaults.Apply( cookieOpts, requireHttps: v.RequireHttpsCookie, idleTimeout: TimeSpan.FromMinutes(v.ExpiryMinutes)); // Keep OtOpcUa's own cookie name (default "ZB.MOM.WW.OtOpcUa.Auth"). cookieOpts.Cookie.Name = v.Name; 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 => { // Named on the selector, not the cookie scheme, so the fallback accepts a bearer token too. o.FallbackPolicy = new AuthorizationPolicyBuilder(OtOpcUaAuthSchemes.CookieOrBearer) .RequireAuthenticatedUser() .Build(); // DriverOperator (policy NAME kept stable): may issue Reconnect/Restart commands against // live driver instances from the Admin UI DriverStatusPanel. The role STRINGS it requires // are the canonical control-plane roles: Operator (was DriverOperator) and // Administrator (was FleetAdmin). Map LDAP group → role via GroupToRole in appsettings // (e.g. "ot-driver-operator": "Operator"). o.AddPolicy(AdminUiPolicies.DriverOperator, policy => policy.RequireRole(DevAuthRoles.Operator, "Administrator")); // FleetAdmin (policy NAME kept stable): full administrative access; gates fleet-wide pages // such as RoleGrants. Requires the canonical Administrator role (was FleetAdmin). o.AddPolicy(AdminUiPolicies.FleetAdmin, policy => policy.RequireRole("Administrator")); // ConfigEditor: may author configuration (UNS, equipment, clusters, drivers incl. live // ResilienceConfig, ACLs, namespaces, scripts) and trigger deploys. Same semantics the // Deployments/Scripts/ScriptEdit pages previously spelled as Roles="Administrator,Designer". o.AddPolicy(AdminUiPolicies.ConfigEditor, policy => policy.RequireRole("Administrator", "Designer")); // AuthenticatedRead: explicit name for "any authenticated user" so read-only pages can carry // a non-bare attribute and the page-authorization guard can ban bare [Authorize] outright. // Semantically identical to the FallbackPolicy. o.AddPolicy(AdminUiPolicies.AuthenticatedRead, policy => policy.RequireAuthenticatedUser()); }); return services; } }