fix(security): validate the API-key pepper at start, finish JWT bearer, circuit-aware audit actor

- ApiKeyOptionsValidator (ValidateOnStart on any driver/admin node): a blank or placeholder
  Security:ApiKeys:Pepper fails host start instead of rejecting every key after a green boot (R03:2.3).
- JWT is finished and opt-in by key (R03:2.4): with Security:Jwt:SigningKey set, a Bearer scheme
  (JwtBearerAuthenticationHandler over JwtTokenService.TryValidate / BuildValidationParameters, fail
  closed) accepts the tokens /auth/token mints; the default scheme is a cookie-or-bearer policy scheme
  and the fallback policy names it, so /api/* and every RequireAuthorization endpoint take a browser
  session OR a machine token. JwtOptions are validated at start (JwtOptionsValidator). Without a key,
  /auth/token is not mapped (404, not the former constructor 500) and no bearer scheme exists.
  A direct AddJwtBearer needs Microsoft.AspNetCore.Authentication.JwtBearer in
  Directory.Packages.props (not this lane's file) — the handler is a drop-in to swap.
- AuthenticationStateAuditActorAccessor is the registered IAuditActorAccessor: the Blazor circuit's
  principal first, HttpContext as the fallback (R03:2.5). HttpAuditActorAccessor shares its resolver.
- CookieAuthenticationStateProvider (unregistered, fire-and-forget ping loop) is deleted (R03:1.5/2.5).
- /auth/login logs the LDAP exception at Warning before answering 503 (R03:2.7).
- Admin operations reply with a fixed message + correlation id instead of ex.Message (R03:4.1).

Refs #733

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 10:23:14 -04:00
parent f5579e6ef2
commit f72af76e99
17 changed files with 773 additions and 127 deletions
@@ -19,9 +19,12 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap;
namespace ZB.MOM.WW.OtOpcUa.Security;
/// <summary>
/// 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>.
/// DI registration for OtOpcUa auth. The cookie scheme carries the browser session; when
/// <c>Security:Jwt:SigningKey</c> is configured a <see cref="OtOpcUaAuthSchemes.Bearer"/> scheme
/// accepts the tokens <c>/auth/token</c> mints (R03:2.4), and the default scheme is a policy scheme
/// that forwards to whichever the request presents — so <c>/api/*</c> and every
/// <c>RequireAuthorization</c> endpoint take a browser session OR a machine token. Matches
/// ScadaBridge structurally — see <c>docs/plans/2026-05-29-auth-alignment-design.md</c>.
/// </summary>
public static class ServiceCollectionExtensions
{
@@ -36,7 +39,14 @@ public static class ServiceCollectionExtensions
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<JwtOptions>().Bind(configuration.GetSection(JwtOptions.SectionName));
// 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<JwtOptions>()
.Bind(configuration.GetSection(JwtOptions.SectionName))
.ValidateOnStart();
services.AddSingleton<IValidateOptions<JwtOptions>, JwtOptionsValidator>();
var jwtConfigured = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()?.IsConfigured ?? false;
services.AddOptions<OtOpcUaCookieOptions>().Bind(configuration.GetSection(OtOpcUaCookieOptions.SectionName));
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
services.AddOptions<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName));
@@ -53,7 +63,10 @@ public static class ServiceCollectionExtensions
// 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.
services.TryAddScoped<IAuditActorAccessor, HttpAuditActorAccessor>();
// 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<IAuditActorAccessor, AuthenticationStateAuditActorAccessor>();
// Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it
// wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton
@@ -78,7 +91,26 @@ public static class ServiceCollectionExtensions
.GetSection(AuthDisableLoginOptions.SectionName)
.GetValue<bool>(nameof(AuthDisableLoginOptions.DisableLogin));
var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
// 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)
{
authBuilder.AddScheme<AuthenticationSchemeOptions, JwtBearerAuthenticationHandler>(
OtOpcUaAuthSchemes.Bearer, "OtOpcUa bearer token", _ => { });
}
if (disableLogin)
{
// DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered
@@ -142,8 +174,8 @@ public static class ServiceCollectionExtensions
services.AddAuthorization(o =>
{
o.FallbackPolicy = new AuthorizationPolicyBuilder(
CookieAuthenticationDefaults.AuthenticationScheme)
// Named on the selector, not the cookie scheme, so the fallback accepts a bearer token too.
o.FallbackPolicy = new AuthorizationPolicyBuilder(OtOpcUaAuthSchemes.CookieOrBearer)
.RequireAuthenticatedUser()
.Build();