diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigServeAuthInterceptor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigServeAuthInterceptor.cs index 91aad7c7..0c0bda38 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigServeAuthInterceptor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ConfigServeAuthInterceptor.cs @@ -110,7 +110,9 @@ public sealed class ConfigServeAuthInterceptor : Interceptor return; var expected = _options.Value.ApiKey; - if (string.IsNullOrEmpty(expected)) + // Whitespace-only counts as unconfigured: an all-blank expected key would otherwise be + // satisfied by an all-blank bearer token, which is no gate at all. + if (string.IsNullOrWhiteSpace(expected)) { _logger.LogWarning( "Rejected a config-serve call to {Method}: no ConfigServe:ApiKey is configured, so the " + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSyncAuthInterceptor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSyncAuthInterceptor.cs index a16acd61..fc6ba41e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSyncAuthInterceptor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LocalDbSyncAuthInterceptor.cs @@ -115,7 +115,9 @@ public sealed class LocalDbSyncAuthInterceptor : Interceptor return; var expected = _options.Value.ApiKey; - if (string.IsNullOrEmpty(expected)) + // Whitespace-only counts as unconfigured: an all-blank expected key would otherwise be + // satisfied by an all-blank bearer token, which is no gate at all. + if (string.IsNullOrWhiteSpace(expected)) { _logger.LogWarning( "Rejected a LocalDb sync call to {Method}: no LocalDb:Replication:ApiKey is configured, " + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/TelemetryStreamAuthInterceptor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/TelemetryStreamAuthInterceptor.cs index 7f331c7b..849cc46e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/TelemetryStreamAuthInterceptor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/TelemetryStreamAuthInterceptor.cs @@ -117,7 +117,9 @@ public sealed class TelemetryStreamAuthInterceptor : Interceptor return; var expected = _options.Value.ApiKey; - if (string.IsNullOrEmpty(expected)) + // Whitespace-only counts as unconfigured: an all-blank expected key would otherwise be + // satisfied by an all-blank bearer token, which is no gate at all. + if (string.IsNullOrWhiteSpace(expected)) { _logger.LogWarning( "Rejected a telemetry stream call to {Method}: no Telemetry:ApiKey is configured, so " + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ConfigServeAuthInterceptorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ConfigServeAuthInterceptorTests.cs index 333772d4..12f3ac27 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ConfigServeAuthInterceptorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/ConfigServeAuthInterceptorTests.cs @@ -65,6 +65,19 @@ public sealed class ConfigServeAuthInterceptorTests ex.StatusCode.ShouldBe(StatusCode.Unauthenticated); } + [Fact] + public async Task FetchMethod_WithBlankKeyConfigured_IsDenied() + { + // Blank is distinct from unset in configuration (a stray-whitespace appsettings value) but + // must be treated identically here: an all-blank expected key would be satisfied by an + // all-blank bearer. Pinned identically on MeshCommandAuthInterceptor. + var interceptor = CreateInterceptor(" "); + var context = CreateContext(FetchMethod, "Bearer "); + + var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); + ex.StatusCode.ShouldBe(StatusCode.Unauthenticated); + } + [Fact] public async Task FetchMethod_WithNoBearerToken_IsDenied() { @@ -121,6 +134,16 @@ public sealed class ConfigServeAuthInterceptorTests ex.StatusCode.ShouldBe(StatusCode.Unauthenticated); } + [Fact] + public void HasExactlyOnePublicConstructor() + { + // Grpc.AspNetCore silently stops invoking an interceptor with more than one public ctor — + // this pins the shape so a well-meaning overload doesn't quietly disable auth in production. + var publicCtors = typeof(ConfigServeAuthInterceptor).GetConstructors(); + + publicCtors.Length.ShouldBe(1); + } + /// /// Minimal carrying just a method name and request headers — /// the only two things the interceptor reads. Hand-rolled because diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSyncAuthInterceptorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSyncAuthInterceptorTests.cs index 9a506376..959d52bd 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSyncAuthInterceptorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LocalDbSyncAuthInterceptorTests.cs @@ -99,6 +99,19 @@ public sealed class LocalDbSyncAuthInterceptorTests ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } + [Fact] + public async Task SyncMethod_WithBlankKeyConfigured_IsDenied() + { + // Blank is distinct from unset in configuration (a stray-whitespace appsettings value) but + // must be treated identically here: an all-blank expected key would be satisfied by an + // all-blank bearer. Pinned identically on MeshCommandAuthInterceptor. + var interceptor = CreateInterceptor(" "); + var context = CreateContext(SyncMethod, "Bearer "); + + var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); + ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); + } + [Fact] public async Task SyncMethod_WithNoBearerToken_IsDenied() { @@ -152,6 +165,16 @@ public sealed class LocalDbSyncAuthInterceptorTests ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } + [Fact] + public void HasExactlyOnePublicConstructor() + { + // Grpc.AspNetCore silently stops invoking an interceptor with more than one public ctor — + // this pins the shape so a well-meaning overload doesn't quietly disable auth in production. + var publicCtors = typeof(LocalDbSyncAuthInterceptor).GetConstructors(); + + publicCtors.Length.ShouldBe(1); + } + [Fact] public async Task DuplexStreaming_IsGated_BecauseThatIsHowSyncActuallyRuns() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/TelemetryStreamAuthInterceptorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/TelemetryStreamAuthInterceptorTests.cs index 287945d7..811f5f5b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/TelemetryStreamAuthInterceptorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/TelemetryStreamAuthInterceptorTests.cs @@ -65,6 +65,19 @@ public sealed class TelemetryStreamAuthInterceptorTests ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); } + [Fact] + public async Task SubscribeMethod_WithBlankKeyConfigured_IsDenied() + { + // Blank is distinct from unset in configuration (a stray-whitespace appsettings value) but + // must be treated identically here: an all-blank expected key would be satisfied by an + // all-blank bearer. Pinned identically on MeshCommandAuthInterceptor. + var interceptor = CreateInterceptor(" "); + var context = CreateContext(SubscribeMethod, "Bearer "); + + var ex = await Should.ThrowAsync(() => Invoke(interceptor, context)); + ex.StatusCode.ShouldBe(StatusCode.PermissionDenied); + } + [Fact] public async Task SubscribeMethod_WithNoBearerToken_IsDenied() {