diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs index 8b1c9ddb..e78c926e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/AdminOperationsActor.cs @@ -89,6 +89,18 @@ public sealed class AdminOperationsActor : ReceiveActor Receive(HandleShelveAlarm); } + /// + /// The reply text for an unexpected server-side failure (R03:4.1): a fixed sentence plus the request's + /// correlation id, never ex.Message — a SqlException or UriFormatException message carries + /// connection-string fragments and host names, and the reply reaches any ConfigEditor browser session. + /// The detail is in the central log under the same correlation id. + /// + /// The admin operation that failed. + /// The request's correlation token, as logged alongside the exception. + /// The operator-facing message. + internal static string ServerFault(string operation, object correlationId) => + $"{operation} failed on the server; see the central node log for correlation {correlationId}."; + /// /// Hands a central→node command to the mesh transport router, or publishes it directly when /// no router is wired. @@ -136,8 +148,8 @@ public sealed class AdminOperationsActor : ReceiveActor } catch (Exception ex) { - _log.Error(ex, "AdminOps: Acknowledge failed for alarm {AlarmId}", msg.AlarmId); - replyTo.Tell(new AcknowledgeAlarmResult(false, ex.Message, msg.CorrelationId)); + _log.Error(ex, "AdminOps: Acknowledge failed for alarm {AlarmId} (correlation {CorrelationId})", msg.AlarmId, msg.CorrelationId); + replyTo.Tell(new AcknowledgeAlarmResult(false, ServerFault("Acknowledge", msg.CorrelationId), msg.CorrelationId)); } } @@ -183,8 +195,8 @@ public sealed class AdminOperationsActor : ReceiveActor } catch (Exception ex) { - _log.Error(ex, "AdminOps: Shelve ({Kind}) failed for alarm {AlarmId}", msg.Kind, msg.AlarmId); - replyTo.Tell(new ShelveAlarmResult(false, ex.Message, msg.CorrelationId)); + _log.Error(ex, "AdminOps: Shelve ({Kind}) failed for alarm {AlarmId} (correlation {CorrelationId})", msg.Kind, msg.AlarmId, msg.CorrelationId); + replyTo.Tell(new ShelveAlarmResult(false, ServerFault("Shelve", msg.CorrelationId), msg.CorrelationId)); } } @@ -237,12 +249,12 @@ public sealed class AdminOperationsActor : ReceiveActor } catch (Exception ex) { - _log.Error(ex, "StartDeployment failed for {CreatedBy}", msg.CreatedBy); + _log.Error(ex, "StartDeployment failed for {CreatedBy} (correlation {CorrelationId})", msg.CreatedBy, msg.CorrelationId); replyTo.Tell(new StartDeploymentResult( StartDeploymentOutcome.Rejected, DeploymentId: null, RevisionHash: null, - Message: ex.Message, + Message: ServerFault("StartDeployment", msg.CorrelationId), msg.CorrelationId)); return; } @@ -269,12 +281,12 @@ public sealed class AdminOperationsActor : ReceiveActor private void HandleDeployComposeFailed(DeployComposeFailed msg) { _composeInFlight = null; - _log.Error(msg.Error, "StartDeployment failed for {CreatedBy}", msg.Request.CreatedBy); + _log.Error(msg.Error, "StartDeployment failed for {CreatedBy} (correlation {CorrelationId})", msg.Request.CreatedBy, msg.Request.CorrelationId); msg.ReplyTo.Tell(new StartDeploymentResult( StartDeploymentOutcome.Rejected, DeploymentId: null, RevisionHash: null, - Message: msg.Error.Message, + Message: ServerFault("StartDeployment", msg.Request.CorrelationId), msg.Request.CorrelationId)); } @@ -513,10 +525,10 @@ public sealed class AdminOperationsActor : ReceiveActor } catch (Exception ex) { - _log.Error(ex, "Probe for {DriverType} threw", msg.DriverType); + _log.Error(ex, "Probe for {DriverType} threw (correlation {CorrelationId})", msg.DriverType, msg.CorrelationId); return new TestDriverConnectResult( false, - ex.Message, + ServerFault("Test Connect", msg.CorrelationId), null, msg.CorrelationId); } @@ -548,8 +560,8 @@ public sealed class AdminOperationsActor : ReceiveActor } catch (Exception ex) { - _log.Error(ex, "AdminOps: RestartDriver failed for {DriverInstanceId}", msg.DriverInstanceId); - replyTo.Tell(new RestartDriverResult(false, ex.Message, msg.CorrelationId)); + _log.Error(ex, "AdminOps: RestartDriver failed for {DriverInstanceId} (correlation {CorrelationId})", msg.DriverInstanceId, msg.CorrelationId); + replyTo.Tell(new RestartDriverResult(false, ServerFault("RestartDriver", msg.CorrelationId), msg.CorrelationId)); } } @@ -578,8 +590,8 @@ public sealed class AdminOperationsActor : ReceiveActor } catch (Exception ex) { - _log.Error(ex, "AdminOps: ReconnectDriver failed for {DriverInstanceId}", msg.DriverInstanceId); - replyTo.Tell(new ReconnectDriverResult(false, ex.Message, msg.CorrelationId)); + _log.Error(ex, "AdminOps: ReconnectDriver failed for {DriverInstanceId} (correlation {CorrelationId})", msg.DriverInstanceId, msg.CorrelationId); + replyTo.Tell(new ReconnectDriverResult(false, ServerFault("ReconnectDriver", msg.CorrelationId), msg.CorrelationId)); } } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ApiKeyOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ApiKeyOptionsValidator.cs new file mode 100644 index 00000000..eced66a8 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ApiKeyOptionsValidator.cs @@ -0,0 +1,41 @@ +using ZB.MOM.WW.Configuration; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// Fail-fast startup validator for (R03:2.3). The pepper used to be +/// checked only when the first key was verified — ApiKeySecret.Verify throws on an empty one — +/// so a site node deployed without Security__ApiKeys__Pepper booted green, passed health, +/// applied deployments and denied every machine client with the same opaque "Invalid API key" until +/// someone read the log; a central node with a wrong pepper minted keys that could verify nowhere. +/// Every other security section already fails host start on a bad value; this one now does too. +/// +/// +/// Registered for hasDriver || hasAdmin: a driver node needs the pepper to VERIFY keys, an admin +/// node to MINT them. The minimum length is a floor against a placeholder ("x") passing as +/// configured, not a strength claim — the pepper is a server-side secret, not a password. +/// +public sealed class ApiKeyOptionsValidator : OptionsValidatorBase +{ + /// The shortest pepper accepted (characters, after trimming). + public const int MinPepperLength = 16; + + /// + protected override void Validate(ValidationBuilder builder, ApiKeyOptions options) + { + var pepper = options.Pepper?.Trim() ?? string.Empty; + builder.RequireThat(pepper.Length > 0, + $"{ApiKeyOptions.SectionName}:Pepper is required on any node with the driver or admin role — " + + "supply it via Security__ApiKeys__Pepper or a ${secret:} reference. An empty pepper would reject " + + "every API key (driver) or mint keys that can never verify (admin)."); + builder.RequireThat(pepper.Length == 0 || pepper.Length >= MinPepperLength, + $"{ApiKeyOptions.SectionName}:Pepper must be at least {MinPepperLength} characters."); + builder.RequireThat(!string.IsNullOrWhiteSpace(options.Username), + $"{ApiKeyOptions.SectionName}:Username must not be blank."); + builder.RequireThat(options.StoreTimeoutMs >= 0, + $"{ApiKeyOptions.SectionName}:StoreTimeoutMs must be >= 0 (0 disables the deadline)."); + builder.RequireThat(options.StoreCacheSeconds >= 0, + $"{ApiKeyOptions.SectionName}:StoreCacheSeconds must be >= 0 (0 disables the cache)."); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index d326d1b1..b2300ab6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -162,10 +162,15 @@ builder.AddZbSerilog(o => o.ServiceName = "otopcua"); // central over the fetch-and-cache path (ConfigSource:Mode=FetchAndCache). Registering it // unconditionally would throw on a driver-only node, since AddOtOpcUaConfigDb requires the string. // A fused admin+driver node keeps ConfigDb via its admin role — byte-for-byte unaffected. -// bound unconditionally: a driver node needs the pepper to VERIFY keys, an admin node needs it -// to MINT them, and getting this wrong on either side is a silent authentication failure rather than a -// startup error. -builder.Services.Configure(builder.Configuration.GetSection(ApiKeyOptions.SectionName)); +// A driver node needs the pepper to VERIFY keys, an admin node needs it to MINT them, and getting this +// wrong on either side used to be a silent authentication failure rather than a startup error (R03:2.3): +// ApiKeyOptionsValidator now fails host start on a blank or placeholder pepper wherever either role is +// present. A node with neither role (dev-only) has no key path and keeps the plain bind. +if (hasDriver || hasAdmin) + builder.Services.AddValidatedOptions( + builder.Configuration, ApiKeyOptions.SectionName); +else + builder.Services.Configure(builder.Configuration.GetSection(ApiKeyOptions.SectionName)); // Security:Acl:Enabled. Default false; see AclOptions for why the flip must be a config // change rather than a redeploy, and what it swaps rather than adds. builder.Services.Configure(builder.Configuration.GetSection(AclOptions.SectionName)); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuthenticationStateAuditActorAccessor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuthenticationStateAuditActorAccessor.cs new file mode 100644 index 00000000..d689b85e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/AuthenticationStateAuditActorAccessor.cs @@ -0,0 +1,75 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Http; +using ZB.MOM.WW.Auth.AspNetCore; + +namespace ZB.MOM.WW.OtOpcUa.Security.Audit; + +/// +/// that reads the Blazor circuit's principal first (R03:2.5). +/// +/// +/// +/// reads IHttpContextAccessor, which is right for a +/// minimal-API request but wrong inside a Blazor Server circuit: after the first render there is no +/// current HttpContext on the circuit's threads, so every audit row written from an interactive +/// page would have been stamped "system". The circuit's identity lives in the scoped +/// , so that is consulted first and the HTTP context is the +/// fallback for non-circuit scopes (where the provider is not registered at all). +/// +/// +/// CurrentActor is synchronous by contract; the server-side provider's +/// is already completed in a +/// circuit, and an incomplete task is treated as "no circuit identity" rather than blocked on. +/// +/// +public sealed class AuthenticationStateAuditActorAccessor : IAuditActorAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly AuthenticationStateProvider? _authenticationState; + + /// Initializes a new instance of the class. + /// The HTTP context accessor (minimal-API fallback). + /// The circuit's authentication state, when resolved inside a Blazor circuit. + public AuthenticationStateAuditActorAccessor( + IHttpContextAccessor httpContextAccessor, + AuthenticationStateProvider? authenticationState = null) + { + ArgumentNullException.ThrowIfNull(httpContextAccessor); + _httpContextAccessor = httpContextAccessor; + _authenticationState = authenticationState; + } + + /// + public string? CurrentActor + { + get + { + if (_authenticationState is not null) + { + var task = _authenticationState.GetAuthenticationStateAsync(); + if (task.IsCompletedSuccessfully) + { + var actor = ResolveActor(task.Result.User); + if (actor is not null) return actor; + } + } + return ResolveActor(_httpContextAccessor.HttpContext?.User); + } + } + + /// The claim-resolution rule shared with . + /// The principal, or null. + /// The actor string, or null when unauthenticated. + internal static string? ResolveActor(ClaimsPrincipal? user) + { + if (user?.Identity?.IsAuthenticated != true) + return null; + + // Prefer the canonical login-name claim; fall back to the Name claim or + // Identity.Name (both of which map to ClaimTypes.Name / ZbClaimTypes.Name). + return user.FindFirst(ZbClaimTypes.Username)?.Value + ?? user.FindFirst(ZbClaimTypes.Name)?.Value + ?? user.Identity.Name; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/HttpAuditActorAccessor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/HttpAuditActorAccessor.cs index 9a86339f..529504b3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/HttpAuditActorAccessor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Audit/HttpAuditActorAccessor.cs @@ -1,5 +1,4 @@ using Microsoft.AspNetCore.Http; -using ZB.MOM.WW.Auth.AspNetCore; namespace ZB.MOM.WW.OtOpcUa.Security.Audit; @@ -17,10 +16,10 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Audit; /// , in that order. /// /// -/// Registered as scoped in -/// so that it correctly follows the request scope used by Blazor Server interactive components -/// and minimal-API endpoints. IHttpContextAccessor is registered by -/// AddOtOpcUaAuth via services.AddHttpContextAccessor(). +/// No longer the registered implementation: is, because +/// a Blazor circuit has no HttpContext after its first render and this accessor would stamp every row +/// written from an interactive page "system". Kept as the explicit HTTP-only variant for non-circuit +/// hosts and tests. /// /// public sealed class HttpAuditActorAccessor : IAuditActorAccessor @@ -35,19 +34,6 @@ public sealed class HttpAuditActorAccessor : IAuditActorAccessor } /// - public string? CurrentActor - { - get - { - var user = _httpContextAccessor.HttpContext?.User; - if (user?.Identity?.IsAuthenticated != true) - return null; - - // Prefer the canonical login-name claim; fall back to the Name claim or - // Identity.Name (both of which map to ClaimTypes.Name / ZbClaimTypes.Name). - return user.FindFirst(ZbClaimTypes.Username)?.Value - ?? user.FindFirst(ZbClaimTypes.Name)?.Value - ?? user.Identity.Name; - } - } + public string? CurrentActor => + AuthenticationStateAuditActorAccessor.ResolveActor(_httpContextAccessor.HttpContext?.User); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/JwtBearerAuthenticationHandler.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/JwtBearerAuthenticationHandler.cs new file mode 100644 index 00000000..cc996758 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/JwtBearerAuthenticationHandler.cs @@ -0,0 +1,99 @@ +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using ZB.MOM.WW.OtOpcUa.Security.Jwt; + +namespace ZB.MOM.WW.OtOpcUa.Security.Auth; + +/// The authentication scheme names OtOpcUa registers. +public static class OtOpcUaAuthSchemes +{ + /// + /// The bearer scheme that validates the HS256 tokens /auth/token mints, through + /// . Registered only when + /// . + /// + public const string Bearer = "Bearer"; + + /// + /// The default scheme: a policy scheme that forwards to when the request + /// carries an Authorization: Bearer header and to the cookie scheme otherwise, so one + /// RequireAuthorization() / policy accepts a browser session AND a machine token. + /// + public const string CookieOrBearer = "OtOpcUa.CookieOrBearer"; + + /// Whether the request presents a bearer token (the selector's whole rule). + /// The request. + /// when an Authorization: Bearer … header is present. + public static bool HasBearerToken(HttpContext context) => + TryGetBearerToken(context.Request.Headers.Authorization, out _); + + internal static bool TryGetBearerToken(StringValues authorization, out string token) + { + token = string.Empty; + var header = authorization.ToString(); + const string prefix = "Bearer "; + if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false; + token = header[prefix.Length..].Trim(); + return token.Length > 0; + } +} + +/// +/// Validates an Authorization: Bearer token minted by /auth/token (R03:2.4). The JWT +/// feature used to be a dead end: tokens were issued and nothing in the product accepted them. This +/// handler is the consuming side, built on so the validation +/// parameters can never drift from the minting side ( +/// sets RoleClaimType/NameClaimType to the minted keys, so [Authorize(Roles)] and +/// IsInRole resolve). +/// +/// +/// Fail closed: no header ⇒ NoResult (the cookie scheme is not consulted by this handler — the +/// policy scheme selects one or the other); a malformed, expired, tampered or wrong-issuer token ⇒ +/// Fail, never a fall-through to the cookie. A direct AddJwtBearer needs the +/// Microsoft.AspNetCore.Authentication.JwtBearer package, which the central package list does +/// not carry; this handler is ~40 lines over the already-referenced System.IdentityModel.Tokens.Jwt +/// and is a drop-in to replace once that package is pinned. +/// +public sealed class JwtBearerAuthenticationHandler : AuthenticationHandler +{ + private readonly JwtTokenService _jwt; + + /// Initializes a new instance of the class. + /// The scheme options monitor. + /// The logger factory. + /// The URL encoder. + /// The token service whose validation parameters mirror its minting side. + public JwtBearerAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + JwtTokenService jwt) + : base(options, logger, encoder) + { + _jwt = jwt; + } + + /// + protected override Task HandleAuthenticateAsync() + { + if (!OtOpcUaAuthSchemes.TryGetBearerToken(Request.Headers.Authorization, out var token)) + return Task.FromResult(AuthenticateResult.NoResult()); + + if (!_jwt.TryValidate(token, out var principal) || principal is null) + return Task.FromResult(AuthenticateResult.Fail("Invalid bearer token.")); + + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name))); + } + + /// + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + Response.StatusCode = StatusCodes.Status401Unauthorized; + Response.Headers.WWWAuthenticate = OtOpcUaAuthSchemes.Bearer; + return Task.CompletedTask; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Blazor/CookieAuthenticationStateProvider.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Blazor/CookieAuthenticationStateProvider.cs deleted file mode 100644 index 0e42b789..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Blazor/CookieAuthenticationStateProvider.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Net.Http.Json; -using System.Security.Claims; -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.Extensions.Logging; - -namespace ZB.MOM.WW.OtOpcUa.Security.Blazor; - -/// -/// Blazor Server that snapshots the cookie-backed -/// principal supplied at circuit boot and polls /auth/ping every 60 seconds to detect -/// expiry. Mirrors ScadaLink's CentralUI implementation. -/// -public sealed class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAsyncDisposable -{ - private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(60); - - private readonly HttpClient _http; - private readonly ILogger _logger; - private readonly CancellationTokenSource _cts = new(); - private ClaimsPrincipal _current; - private Task? _pingLoop; - - /// Initializes a new instance of the CookieAuthenticationStateProvider class. - /// The initial claims principal from circuit boot. - /// The HTTP client for authentication ping requests. - /// The logger for diagnostic messages. - public CookieAuthenticationStateProvider( - ClaimsPrincipal initial, - HttpClient http, - ILogger logger) - { - _current = initial; - _http = http; - _logger = logger; - } - - /// - public override Task GetAuthenticationStateAsync() - { - _pingLoop ??= Task.Run(() => PingLoopAsync(_cts.Token)); - return Task.FromResult(new AuthenticationState(_current)); - } - - private async Task PingLoopAsync(CancellationToken ct) - { - try - { - while (!ct.IsCancellationRequested) - { - await Task.Delay(PingInterval, ct).ConfigureAwait(false); - var resp = await _http.GetAsync("/auth/ping", ct).ConfigureAwait(false); - if (!resp.IsSuccessStatusCode && _current.Identity?.IsAuthenticated == true) - { - _logger.LogInformation("/auth/ping returned {Code}; notifying circuit", (int)resp.StatusCode); - _current = new ClaimsPrincipal(new ClaimsIdentity()); - NotifyAuthenticationStateChanged( - Task.FromResult(new AuthenticationState(_current))); - } - } - } - catch (OperationCanceledException) { /* expected on shutdown */ } - catch (Exception ex) - { - _logger.LogWarning(ex, "Auth ping loop terminated unexpectedly"); - } - } - - /// Cancels the background ping loop, awaits its completion, and disposes the cancellation token source. - /// A task that represents the asynchronous operation. - public async ValueTask DisposeAsync() - { - _cts.Cancel(); - if (_pingLoop is not null) - { - try { await _pingLoop.ConfigureAwait(false); } catch { /* swallow shutdown errors */ } - } - _cts.Dispose(); - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs index 56ac6ef7..6b0876d7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; 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.Security.Jwt; @@ -36,7 +37,14 @@ public static class AuthEndpoints // AddOtOpcUaAuth fallback policy that would otherwise 401 the request. app.MapPost("/auth/login", (Delegate)LoginAsync).AllowAnonymous().DisableAntiforgery(); app.MapGet("/auth/ping", (Delegate)Ping).AllowAnonymous(); - app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization(); + // Mapped only when a signing key is configured (JwtOptions.IsConfigured): an unconfigured + // endpoint used to answer HTTP 500 from the JwtTokenService constructor on every call. + if (app.ServiceProvider.GetRequiredService>().Value.IsConfigured) + app.MapPost("/auth/token", (Delegate)IssueToken).RequireAuthorization(); + else + app.ServiceProvider.GetService()? + .CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints") + .LogInformation("Security:Jwt:SigningKey is not configured — /auth/token is not mapped and bearer tokens are not accepted"); app.MapPost("/auth/logout", (Delegate)LogoutAsync).RequireAuthorization(); return app; } @@ -73,8 +81,13 @@ public static class AuthEndpoints { result = await ldap.AuthenticateAsync(username, password, ct); } - catch (Exception) + catch (Exception ex) when (ex is not OperationCanceledException) { + // R03:2.7 — a directory outage used to leave NOTHING in the server log from this path; the + // operator had to infer every 503 from the directory side. + http.RequestServices.GetService()? + .CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints") + .LogWarning(ex, "LDAP authentication failed for {User}; answering 503", username); return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs index 07cbdda4..f523a033 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptions.cs @@ -15,4 +15,26 @@ public sealed class JwtOptions /// Default token expiry. Mirrors ScadaLink (15 min). public int ExpiryMinutes { get; set; } = 15; + + /// + /// Whether the JWT feature is on: a non-blank . When off, /auth/token + /// is not mapped and no bearer scheme is registered, so an admin node that never configured a key + /// keeps booting exactly as before; when on, the key is validated at host start + /// (ValidateOnStart) instead of throwing HTTP 500 on the first token request. + /// + public bool IsConfigured => !string.IsNullOrWhiteSpace(SigningKey); + + /// The validation rule shared by the startup validator and the tests: why the options are invalid, or null. + /// A failure message, or when valid. + public string? Validate() + { + if (!IsConfigured) return null; + var keyBytes = System.Text.Encoding.UTF8.GetByteCount(SigningKey); + if (keyBytes < MinSigningKeyBytes) + return $"{SectionName}:SigningKey must be at least {MinSigningKeyBytes} bytes (256 bits) UTF-8 for HMAC-SHA256; the configured key is {keyBytes} byte(s)."; + if (string.IsNullOrWhiteSpace(Issuer)) return $"{SectionName}:Issuer must not be blank."; + if (string.IsNullOrWhiteSpace(Audience)) return $"{SectionName}:Audience must not be blank."; + if (ExpiryMinutes <= 0) return $"{SectionName}:ExpiryMinutes must be > 0."; + return null; + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptionsValidator.cs new file mode 100644 index 00000000..c3805267 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Jwt/JwtOptionsValidator.cs @@ -0,0 +1,19 @@ +using Microsoft.Extensions.Options; + +namespace ZB.MOM.WW.OtOpcUa.Security.Jwt; + +/// +/// Startup validator for (R03:2.4): when a signing key is configured it must +/// be usable, and the host must learn that at start (ValidateOnStart) rather than from an HTTP 500 +/// on the first /auth/token. An unconfigured section (no key) is valid — the feature is simply off. +/// +public sealed class JwtOptionsValidator : IValidateOptions +{ + /// + public ValidateOptionsResult Validate(string? name, JwtOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var failure = options.Validate(); + return failure is null ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failure); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index 6df24cad..d58fbcf3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -19,9 +19,12 @@ using ZB.MOM.WW.OtOpcUa.Security.Ldap; namespace ZB.MOM.WW.OtOpcUa.Security; /// -/// 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. +/// 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 { @@ -36,7 +39,14 @@ public static class ServiceCollectionExtensions /// The same service collection, for chaining. public static IServiceCollection AddOtOpcUaAuth(this IServiceCollection services, IConfiguration configuration) { - services.AddOptions().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() + .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)); @@ -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(); + // 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 @@ -78,7 +91,26 @@ public static class ServiceCollectionExtensions .GetSection(AuthDisableLoginOptions.SectionName) .GetValue(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( + 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(); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorServerFaultMessageTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorServerFaultMessageTests.cs new file mode 100644 index 00000000..f4e6fd22 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests/AdminOperationsActorServerFaultMessageTests.cs @@ -0,0 +1,68 @@ +using Akka.Actor; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.ControlPlane.AdminOperations; +using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests; + +/// +/// R03:4.1 — admin operations echoed ex.Message to the UI, so a SqlException's connection-string +/// fragment reached any ConfigEditor browser session. The reply is now a fixed sentence plus the request's +/// correlation id; the detail stays in the central log. +/// +public sealed class AdminOperationsActorServerFaultMessageTests : ControlPlaneActorTestBase +{ + private const string Secret = "Server=sql-prod-01;Password=hunter2"; + + [Fact] + public void StartDeployment_server_fault_reply_carries_the_correlation_id_and_not_the_exception_text() + { + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props(new ThrowingDbFactory(), coordinator.Ref, Enumerable.Empty())); + var correlationId = CorrelationId.NewId(); + + actor.Tell(new StartDeployment("joe", correlationId)); + + var reply = ExpectMsg(PresenceBudget); + reply.Outcome.ShouldBe(StartDeploymentOutcome.Rejected); + reply.Message.ShouldNotBeNull(); + reply.Message.ShouldNotContain("hunter2"); + reply.Message.ShouldNotContain("sql-prod-01"); + reply.Message.ShouldContain(correlationId.ToString()); // the operator can find the log line + } + + [Fact] + public void TestDriverConnect_probe_throw_reply_carries_the_correlation_id_and_not_the_exception_text() + { + var coordinator = CreateTestProbe("coord"); + var actor = Sys.ActorOf(AdminOperationsActor.Props(NewInMemoryDbFactory(), coordinator.Ref, new IDriverProbe[] { new ThrowingProbe() })); + var correlationId = Guid.NewGuid(); + + actor.Tell(new TestDriverConnect("Throwing", "{}", 5, correlationId)); + + var reply = ExpectMsg(PresenceBudget); + reply.Ok.ShouldBeFalse(); + reply.Message.ShouldNotBeNull(); + reply.Message.ShouldNotContain("hunter2"); + reply.Message.ShouldContain(correlationId.ToString()); + } + + private sealed class ThrowingDbFactory : IDbContextFactory + { + public OtOpcUaConfigDbContext CreateDbContext() => + throw new InvalidOperationException("Cannot open database: " + Secret); + } + + private sealed class ThrowingProbe : IDriverProbe + { + public string DriverType => "Throwing"; + public Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct) => + throw new InvalidOperationException("probe blew up: " + Secret); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ApiKeyOptionsValidatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ApiKeyOptionsValidatorTests.cs new file mode 100644 index 00000000..e91fbda0 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ApiKeyOptionsValidatorTests.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration; + +/// +/// R03:2.3 — an unset or placeholder pepper must fail host start, not surface as "Invalid API key" +/// on the first activation. +/// +public sealed class ApiKeyOptionsValidatorTests +{ + private static ValidateOptionsResult Validate(ApiKeyOptions options) => + new ApiKeyOptionsValidator().Validate(Options.DefaultName, options); + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Blank_pepper_fails(string pepper) + { + var result = Validate(new ApiKeyOptions { Pepper = pepper }); + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull(); + result.FailureMessage.ShouldContain("Pepper is required"); + } + + [Fact] + public void Short_pepper_fails() + { + var result = Validate(new ApiKeyOptions { Pepper = "x" }); + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull(); + result.FailureMessage.ShouldContain($"at least {ApiKeyOptionsValidator.MinPepperLength}"); + } + + [Fact] + public void Blank_username_fails() + { + var result = Validate(new ApiKeyOptions { Pepper = new string('p', 32), Username = " " }); + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldNotBeNull(); + result.FailureMessage.ShouldContain("Username"); + } + + /// Positive control: the docker-dev rig's pepper and the defaults pass unchanged. + [Fact] + public void Configured_pepper_passes() + { + Validate(new ApiKeyOptions { Pepper = "docker-dev-apikey-pepper-not-for-production" }) + .Succeeded.ShouldBeTrue(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuthenticationStateAuditActorAccessorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuthenticationStateAuditActorAccessorTests.cs new file mode 100644 index 00000000..5dc1f391 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/Audit/AuthenticationStateAuditActorAccessorTests.cs @@ -0,0 +1,80 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Http; +using Shouldly; +using Xunit; +using ZB.MOM.WW.Auth.AspNetCore; +using ZB.MOM.WW.OtOpcUa.Security.Audit; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests.Audit; + +/// +/// R03:2.5 — inside a Blazor circuit there is no HttpContext after the first render, so an +/// HTTP-only accessor stamps every audit row from an interactive page "system". The circuit's +/// is consulted first; the HTTP context is the fallback. +/// +public sealed class AuthenticationStateAuditActorAccessorTests +{ + [Fact] + public void Circuit_principal_wins_when_there_is_no_http_context() + { + var accessor = new AuthenticationStateAuditActorAccessor( + new HttpContextAccessorStub(null), + new FixedStateProvider(Authenticated(new Claim(ZbClaimTypes.Username, "alice")))); + + accessor.CurrentActor.ShouldBe("alice"); + } + + [Fact] + public void Http_principal_is_the_fallback_outside_a_circuit() + { + var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Username, "bob")) }; + var accessor = new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), authenticationState: null); + + accessor.CurrentActor.ShouldBe("bob"); + } + + [Fact] + public void Unauthenticated_circuit_falls_through_to_http_then_null() + { + var anonymousCircuit = new FixedStateProvider(new ClaimsPrincipal(new ClaimsIdentity())); + + new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(null), anonymousCircuit) + .CurrentActor.ShouldBeNull(); + + var context = new DefaultHttpContext { User = Authenticated(new Claim(ZbClaimTypes.Name, "carol")) }; + new AuthenticationStateAuditActorAccessor(new HttpContextAccessorStub(context), anonymousCircuit) + .CurrentActor.ShouldBe("carol"); + } + + /// An incomplete state task is "no circuit identity", never a block. + [Fact] + public void Pending_authentication_state_is_not_awaited() + { + var accessor = new AuthenticationStateAuditActorAccessor( + new HttpContextAccessorStub(null), + new PendingStateProvider()); + + accessor.CurrentActor.ShouldBeNull(); + } + + private static ClaimsPrincipal Authenticated(params Claim[] claims) => + new(new ClaimsIdentity(claims, "TestScheme", ZbClaimTypes.Name, ZbClaimTypes.Role)); + + private sealed class HttpContextAccessorStub(HttpContext? context) : IHttpContextAccessor + { + public HttpContext? HttpContext { get; set; } = context; + } + + private sealed class FixedStateProvider(ClaimsPrincipal user) : AuthenticationStateProvider + { + public override Task GetAuthenticationStateAsync() => + Task.FromResult(new AuthenticationState(user)); + } + + private sealed class PendingStateProvider : AuthenticationStateProvider + { + public override Task GetAuthenticationStateAsync() => + new TaskCompletionSource().Task; + } +} 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 9cf8ab11..e5e3b620 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AuthEndpointsIntegrationTests.cs @@ -11,6 +11,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; using Shouldly; using Xunit; using ZB.MOM.WW.Auth.AspNetCore; @@ -37,6 +38,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime private IHost _host = null!; private TestServer _server = null!; private readonly StubLdapGroupRoleMappingService _roleMappings = new(); + private readonly CapturingLoggerProvider _logs = new(); private static CancellationToken Ct => TestContext.Current.CancellationToken; @@ -70,6 +72,7 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime ["Security:Ldap:GroupToRole:ReadOnly"] = "Viewer", }).Build(); services.AddOtOpcUaAuth(configuration); + services.AddLogging(l => l.AddProvider(_logs)); services.AddSingleton(); // The login handler now resolves the DB role-map service via DI to merge // DB-backed grants on top of the appsettings baseline. Register the stub so @@ -96,6 +99,10 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime .ToArray(); return Results.Ok(claims); }).RequireAuthorization(); + // A role-gated machine endpoint: proves a bearer principal's roles resolve through + // IsInRole (the validation parameters pair RoleClaimType with the minted key). + e.MapGet("/api/viewer-only", () => Results.Ok("viewer")) + .RequireAuthorization(p => p.RequireRole("Viewer")); }); }); }) @@ -153,6 +160,66 @@ public sealed class AuthEndpointsIntegrationTests : IAsyncLifetime new AuthEndpoints.LoginRequest("ldap-down", "anything"), Ct); response.StatusCode.ShouldBe(HttpStatusCode.ServiceUnavailable); + // R03:2.7 — the outage is named in the server log, not inferred from the directory side. + _logs.Entries.Any(e => + e.Level == LogLevel.Warning && e.Message.Contains("LDAP authentication failed") && e.Exception != null) + .ShouldBeTrue("a Warning naming the LDAP failure must be logged"); + } + + /// + /// R03:2.4 — a token from /auth/token authenticates a request carrying it as a bearer: + /// the fallback-protected root AND a role-gated endpoint (the bearer principal's "Role" claims + /// resolve through IsInRole). No cookie is sent. + /// + [Fact] + public async Task Bearer_token_authenticates_api_requests_without_a_cookie() + { + var token = await LoginAndIssueTokenAsync(); + var client = NewClient(); + + var root = new HttpRequestMessage(HttpMethod.Get, "/"); + root.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + (await client.SendAsync(root, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK); + + var gated = new HttpRequestMessage(HttpMethod.Get, "/api/viewer-only"); + gated.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + var gatedResponse = await client.SendAsync(gated, Ct); + gatedResponse.StatusCode.ShouldBe(HttpStatusCode.OK); + (await gatedResponse.Content.ReadAsStringAsync(Ct)).ShouldContain("viewer"); + } + + /// A tampered or garbage bearer fails closed (401), it never falls through to anonymous. + [Theory] + [InlineData("not-a-jwt")] + [InlineData("tampered")] + public async Task Bad_bearer_token_is_rejected(string kind) + { + var token = kind == "tampered" + ? (await LoginAndIssueTokenAsync()) + "x" + : kind; + var client = NewClient(); + var req = new HttpRequestMessage(HttpMethod.Get, "/"); + req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + req.Headers.Add("X-Requested-With", "XMLHttpRequest"); + + var response = await client.SendAsync(req, Ct); + + response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); + response.Headers.WwwAuthenticate.ToString().ShouldContain("Bearer"); + } + + private async Task LoginAndIssueTokenAsync() + { + var client = NewClient(); + var loginResponse = await client.PostAsJsonAsync("/auth/login", + new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct); + loginResponse.EnsureSuccessStatusCode(); + var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token"); + AttachCookies(tokenReq, loginResponse); + var response = await client.SendAsync(tokenReq, Ct); + response.EnsureSuccessStatusCode(); + var payload = await response.Content.ReadFromJsonAsync(Ct); + return payload.GetProperty("token").GetString()!; } /// Tests that ping anonymous returns 401. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/CapturingLoggerProvider.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/CapturingLoggerProvider.cs new file mode 100644 index 00000000..a79ab569 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/CapturingLoggerProvider.cs @@ -0,0 +1,25 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests; + +/// Collects every log entry written through the host's logger factory, for assertions on what was logged. +internal sealed class CapturingLoggerProvider : ILoggerProvider +{ + public ConcurrentQueue Entries { get; } = new(); + + public ILogger CreateLogger(string categoryName) => new Logger(categoryName, Entries); + + public void Dispose() { } + + internal sealed record Entry(string Category, LogLevel Level, string Message, Exception? Exception); + + private sealed class Logger(string category, ConcurrentQueue entries) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + entries.Enqueue(new Entry(category, logLevel, formatter(state, exception), exception)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtConfigurationGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtConfigurationGateTests.cs new file mode 100644 index 00000000..96fc88ca --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/JwtConfigurationGateTests.cs @@ -0,0 +1,127 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Services; +using ZB.MOM.WW.OtOpcUa.Security.Auth; +using ZB.MOM.WW.OtOpcUa.Security.Endpoints; +using ZB.MOM.WW.OtOpcUa.Security.Jwt; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests; + +/// +/// R03:2.4 — JWT is opt-in by signing key. Unconfigured: /auth/token is not mapped (it used to +/// answer HTTP 500 from the service constructor) and no bearer scheme exists. Configured badly: the +/// host refuses to START rather than failing on the first token request. +/// +public sealed class JwtConfigurationGateTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task Without_a_signing_key_auth_token_is_not_mapped_and_bearer_is_ignored() + { + using var host = await StartHostAsync(signingKey: null); + var server = host.GetTestServer(); + var client = server.CreateClient(); + + // The login still works — the cookie path is untouched. + var login = await client.PostAsJsonAsync("/auth/login", new AuthEndpoints.LoginRequest("alice", "valid-password"), Ct); + login.StatusCode.ShouldBe(HttpStatusCode.NoContent); + + // /auth/token is simply absent (404), not a 500. + var tokenReq = new HttpRequestMessage(HttpMethod.Post, "/auth/token"); + foreach (var c in login.Headers.GetValues("Set-Cookie")) tokenReq.Headers.Add("Cookie", c.Split(';')[0]); + (await client.SendAsync(tokenReq, Ct)).StatusCode.ShouldBe(HttpStatusCode.NotFound); + + // No bearer scheme is registered, so a bearer header is ignored and the cookie session still wins. + var schemes = host.Services.GetRequiredService(); + (await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer)).ShouldBeNull(); + var withBogusBearer = new HttpRequestMessage(HttpMethod.Get, "/"); + foreach (var c in login.Headers.GetValues("Set-Cookie")) withBogusBearer.Headers.Add("Cookie", c.Split(';')[0]); + withBogusBearer.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "garbage"); + (await client.SendAsync(withBogusBearer, Ct)).StatusCode.ShouldBe(HttpStatusCode.OK); + + await host.StopAsync(Ct); + } + + /// Positive control for the gate: with a key, the bearer scheme IS registered. + [Fact] + public async Task With_a_signing_key_the_bearer_scheme_is_registered() + { + using var host = await StartHostAsync(signingKey: "test-signing-key-with-at-least-32-bytes-of-utf8-content"); + var schemes = host.Services.GetRequiredService(); + var bearer = await schemes.GetSchemeAsync(OtOpcUaAuthSchemes.Bearer); + bearer.ShouldNotBeNull(); + bearer.HandlerType.ShouldBe(typeof(JwtBearerAuthenticationHandler)); + await host.StopAsync(Ct); + } + + /// A configured-but-short key fails at host start (ValidateOnStart), not on the first /auth/token. + [Fact] + public async Task A_short_signing_key_fails_host_start() + { + var ex = await Should.ThrowAsync(async () => + { + using var host = await StartHostAsync(signingKey: "too-short"); + }); + ex.Message.ShouldContain("SigningKey"); + } + + private static async Task StartHostAsync(string? signingKey) + { + var dbName = $"jwt-gate-{Guid.NewGuid():N}"; + var settings = new Dictionary(); + if (signingKey is not null) settings["Security:Jwt:SigningKey"] = signingKey; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + var host = new HostBuilder() + .ConfigureWebHost(web => + { + web.UseTestServer(); + web.ConfigureServices(services => + { + services.AddDbContextFactory(opt => opt.UseInMemoryDatabase(dbName)); + services.AddDbContext(opt => opt.UseInMemoryDatabase(dbName)); + services.AddRouting(); + services.AddOtOpcUaAuth(configuration); + services.AddSingleton(); + services.AddSingleton(); + }); + web.Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(e => + { + e.MapOtOpcUaAuth(); + e.MapGet("/", () => Results.Ok("authenticated")).RequireAuthorization(); + }); + }); + }) + .Build(); + await host.StartAsync(Ct); + return host; + } + + private sealed class AlwaysAliceLdap : ILdapAuthService + { + public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) => + Task.FromResult(new LdapAuthResult( + Success: password == "valid-password", DisplayName: "Alice", Username: username, + Groups: [], Roles: [], Error: null)); + } +}