using System.Runtime.CompilerServices; using Grpc.Core; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Grpc; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Tests.TestSupport; // The handler exposes the gateway's constraint-bearing identity; alias the shared library identity // (returned by the verifier) so the two can be referenced unambiguously. using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity; using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity; namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; public sealed class GatewayGrpcAuthorizationInterceptorTests { private const string AttackerPeer = "ipv4:203.0.113.7:5000"; private const string HolderPeer = "ipv4:198.51.100.4:5000"; private static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(1); private static readonly TimeSpan ProbeInterval = TimeSpan.FromSeconds(5); /// Verifies that missing API key returns unauthenticated status. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(Failure(ApiKeyFailure.MissingOrMalformed)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), new TestServerCallContext([]), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); Assert.DoesNotContain("secret", exception.Status.Detail, StringComparison.OrdinalIgnoreCase); } /// Verifies that invalid API key error does not expose raw credentials. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_super-secret"), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); Assert.DoesNotContain("super-secret", exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that valid key without required scope returns permission denied. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that valid key with scope sets request identity for the handler. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_ValidApiKeyWithScope_SetsRequestIdentity() { GatewayRequestIdentityAccessor identityAccessor = new(); ApiKeyIdentity? identitySeenByHandler = null; GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), identityAccessor); OpenSessionReply reply = await interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => { identitySeenByHandler = identityAccessor.Current; return Task.FromResult(new OpenSessionReply { SessionId = "session-1" }); }); Assert.Equal("session-1", reply.SessionId); Assert.NotNull(identitySeenByHandler); Assert.Equal("operator01", identitySeenByHandler.KeyId); Assert.Null(identityAccessor.Current); } /// Verifies that server stream handler requires proper scope. /// A task that represents the asynchronous operation. [Fact] public async Task ServerStreamingServerHandler_ValidApiKeyMissingScope_ReturnsPermissionDenied() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.ServerStreamingServerHandler( new StreamEventsRequest(), new RecordingServerStreamWriter(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _, _) => Task.CompletedTask)); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that server stream handler allows streams with proper scope. /// A task that represents the asynchronous operation. [Fact] public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream() { GatewayRequestIdentityAccessor identityAccessor = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), identityAccessor); RecordingServerStreamWriter streamWriter = new(); await interceptor.ServerStreamingServerHandler( new StreamEventsRequest(), streamWriter, ContextWithAuthorization("Bearer mxgw_operator01_secret"), async (_, writer, _) => { Assert.Equal("operator01", identityAccessor.Current?.KeyId); await writer.WriteAsync(new MxEvent { SessionId = "session-1" }); }); MxEvent eventMessage = Assert.Single(streamWriter.Messages); Assert.Equal("session-1", eventMessage.SessionId); Assert.Null(identityAccessor.Current); } /// Verifies that disabled authentication skips API key verification. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification() { GatewayRequestIdentityAccessor identityAccessor = new(); FakeApiKeyVerifier verifier = new(Failure(ApiKeyFailure.MissingOrMalformed)); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( verifier, identityAccessor, AuthenticationMode.Disabled); OpenSessionReply reply = await interceptor.UnaryServerHandler( new OpenSessionRequest(), new TestServerCallContext([]), (_, _) => Task.FromResult(new OpenSessionReply { SessionId = "session-1" })); Assert.Equal("session-1", reply.SessionId); Assert.False(verifier.WasCalled); Assert.Null(identityAccessor.Current); } /// /// End-to-end composition test: runs an OpenSession call through the real /// interceptor in front of the real with a key /// that lacks the session:open scope, and asserts the interceptor denies the /// call with before the service runs. /// /// A task that represents the asynchronous operation. [Fact] public async Task InterceptorComposedWithService_OpenSessionMissingScope_DeniesBeforeServiceRuns() { GatewayRequestIdentityAccessor identityAccessor = new(); RecordingSessionManager sessionManager = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), identityAccessor); MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest { ClientSessionName = "operator-session" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (request, context) => service.OpenSession(request, context))); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.SessionOpen, exception.Status.Detail, StringComparison.Ordinal); Assert.Equal(0, sessionManager.OpenSessionCount); } /// /// End-to-end composition test: runs an OpenSession call through the real /// interceptor in front of the real with a key /// that holds session:open, and asserts the service runs and observes the /// interceptor-supplied identity. /// /// A task that represents the asynchronous operation. [Fact] public async Task InterceptorComposedWithService_OpenSessionWithScope_RunsServiceWithIdentity() { GatewayRequestIdentityAccessor identityAccessor = new(); RecordingSessionManager sessionManager = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), identityAccessor); MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); OpenSessionReply reply = await interceptor.UnaryServerHandler( new OpenSessionRequest { ClientSessionName = "operator-session" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (request, context) => service.OpenSession(request, context)); Assert.Equal("session-1", reply.SessionId); Assert.Equal(1, sessionManager.OpenSessionCount); Assert.Equal("Operator Key", sessionManager.LastClientIdentity); } /// /// End-to-end composition test: an Invoke call through the real interceptor in /// front of the real service with a key holding only invoke:read is denied /// because the wrapped command is a write, confirming command-scope mapping is /// enforced through the full composition. /// /// A task that represents the asynchronous operation. [Fact] public async Task InterceptorComposedWithService_InvokeWriteCommandWithReadScope_DeniesBeforeServiceRuns() { GatewayRequestIdentityAccessor identityAccessor = new(); RecordingSessionManager sessionManager = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)), identityAccessor); MxAccessGatewayService service = CreateService(sessionManager, identityAccessor); MxCommandRequest request = new() { SessionId = "session-1", Command = new MxCommand { Kind = MxCommandKind.Write, Write = new WriteCommand { ServerHandle = 1, ItemHandle = 2 }, }, }; RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( request, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (req, context) => service.Invoke(req, context))); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal); Assert.Equal(0, sessionManager.InvokeCount); } /// /// Verifies the interceptor denies AcknowledgeAlarm calls that lack /// . Ack is a write-shaped mutation against /// alarm state, so it carries the same scope as MxCommandKind.Write. /// /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_AcknowledgeAlarmMissingScope_ReturnsPermissionDenied() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new AcknowledgeAlarmRequest { AlarmFullReference = "ref" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => Task.FromResult(new AcknowledgeAlarmReply()))); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that an API key holding invoke:write may call AcknowledgeAlarm. /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_AcknowledgeAlarmWithScope_RunsHandler() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeWrite)), new GatewayRequestIdentityAccessor()); bool handlerRan = false; AcknowledgeAlarmReply reply = await interceptor.UnaryServerHandler( new AcknowledgeAlarmRequest { AlarmFullReference = "ref" }, ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _) => { handlerRan = true; return Task.FromResult(new AcknowledgeAlarmReply()); }); Assert.NotNull(reply); Assert.True(handlerRan); } /// /// Verifies the interceptor denies QueryActiveAlarms server-streaming calls that /// lack . Active-alarm snapshots are part of the /// alarm/event surface and share the same scope as StreamEvents. /// /// A task that represents the asynchronous operation. [Fact] public async Task ServerStreamingServerHandler_QueryActiveAlarmsMissingScope_ReturnsPermissionDenied() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)), new GatewayRequestIdentityAccessor()); RpcException exception = await Assert.ThrowsAsync( () => interceptor.ServerStreamingServerHandler( new QueryActiveAlarmsRequest(), new RecordingServerStreamWriter(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _, _) => Task.CompletedTask)); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal); } /// Verifies that an API key holding events:read may call QueryActiveAlarms. /// A task that represents the asynchronous operation. [Fact] public async Task ServerStreamingServerHandler_QueryActiveAlarmsWithScope_RunsHandler() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), new GatewayRequestIdentityAccessor()); RecordingServerStreamWriter streamWriter = new(); await interceptor.ServerStreamingServerHandler( new QueryActiveAlarmsRequest(), streamWriter, ContextWithAuthorization("Bearer mxgw_operator01_secret"), async (_, writer, _) => { await writer.WriteAsync(new ActiveAlarmSnapshot()); }); Assert.Single(streamWriter.Messages); } /// /// SEC-31: once an attacking peer has exceeded the failure limit for a key id, the interceptor /// short-circuits with BEFORE calling the verifier, so /// an online guessing loop stops spending a store read per attempt. The composite /// (peer, key id) partition keeps that bound per attacking address. /// /// A task that represents the asynchronous operation. [Fact] public async Task BruteForceBound_StillEnforcedPerAttackingPeer() { CountingFailureVerifier verifier = new(Failure(ApiKeyFailure.SecretMismatch)); ManualTimeProvider clock = new(DateTimeOffset.UnixEpoch); ApiKeyFailureLimiter limiter = CreateLimiter(clock, limit: 3); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( verifier, new GatewayRequestIdentityAccessor(), failureLimiter: limiter); // The first three attempts reach the verifier and fail (recording a failure each time). for (int attempt = 0; attempt < 3; attempt++) { RpcException failure = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_bad-secret", AttackerPeer), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.Unauthenticated, failure.StatusCode); } Assert.Equal(3, verifier.CallCount); // The fourth attempt is short-circuited: ResourceExhausted, and the verifier is NOT called. RpcException throttled = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_bad-secret", AttackerPeer), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.ResourceExhausted, throttled.StatusCode); Assert.Equal(3, verifier.CallCount); } /// /// SEC-31 (the lockout inversion): an attacker who floods failures for a victim's key id from its /// own address must not deny that key to the legitimate holder. The holder presents the correct /// secret from a different transport peer and authenticates on the first attempt — the verifier /// is reached and the RPC succeeds. /// /// A task that represents the asynchronous operation. [Fact] public async Task AttackerSpamOnVictimKeyId_FromDifferentPeer_DoesNotBlockLegitimateHolderPresentingCorrectSecret() { ManualTimeProvider clock = new(DateTimeOffset.UnixEpoch); ApiKeyFailureLimiter limiter = CreateLimiter(clock, limit: 3, aggregateLimit: 30); GatewayGrpcAuthorizationInterceptor attacked = CreateInterceptor( new CountingFailureVerifier(Failure(ApiKeyFailure.SecretMismatch)), new GatewayRequestIdentityAccessor(), failureLimiter: limiter); // Flood the victim's key id from the attacker's address until that partition is throttled. StatusCode lastAttackerStatus = StatusCode.OK; for (int attempt = 0; attempt < 6; attempt++) { RpcException failure = await Assert.ThrowsAsync( () => attacked.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_guess", AttackerPeer), (_, _) => Task.FromResult(new OpenSessionReply()))); lastAttackerStatus = failure.StatusCode; } Assert.Equal(StatusCode.ResourceExhausted, lastAttackerStatus); // The legitimate holder, on a different address, is verified and admitted immediately. FakeApiKeyVerifier holderVerifier = new(SuccessWithScopes(GatewayScopes.SessionOpen)); GatewayGrpcAuthorizationInterceptor holder = CreateInterceptor( holderVerifier, new GatewayRequestIdentityAccessor(), failureLimiter: limiter); OpenSessionReply reply = await holder.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_correct", HolderPeer), (_, _) => Task.FromResult(new OpenSessionReply { SessionId = "session-1" })); Assert.True(holderVerifier.WasCalled); Assert.Equal("session-1", reply.SessionId); } /// /// SEC-31 layer 2: failures for one key id sprayed across more distinct peers than /// ApiKeyFailureAggregateLimit put that key id into probe mode globally, so a /// rotating-source attacker gets at most one verifier call per probe interval. /// /// A task that represents the asynchronous operation. [Fact] public async Task AggregateSpray_AcrossManyPeers_TripsPerKeyProbeMode() { CountingFailureVerifier verifier = new(Failure(ApiKeyFailure.SecretMismatch)); ManualTimeProvider clock = new(DateTimeOffset.UnixEpoch); ApiKeyFailureLimiter limiter = CreateLimiter(clock, limit: 100, aggregateLimit: 5); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( verifier, new GatewayRequestIdentityAccessor(), failureLimiter: limiter); for (int peer = 0; peer < 5; peer++) { await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_guess", $"ipv4:10.9.0.{peer}:5000"), (_, _) => Task.FromResult(new OpenSessionReply()))); } Assert.Equal(5, verifier.CallCount); // A never-seen peer is now probe-limited: no composite failures of its own, but the key id's // aggregate is tripped, so the request never reaches the verifier. RpcException throttled = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_guess", "ipv4:10.9.1.1:5000"), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.ResourceExhausted, throttled.StatusCode); Assert.Equal(5, verifier.CallCount); // One probe slot opens per interval, and it is consumed by the first arrival. clock.Advance(ProbeInterval); RpcException probed = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_guess", "ipv4:10.9.1.2:5000"), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.Unauthenticated, probed.StatusCode); Assert.Equal(6, verifier.CallCount); RpcException throttledAgain = await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_guess", "ipv4:10.9.1.3:5000"), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.ResourceExhausted, throttledAgain.StatusCode); Assert.Equal(6, verifier.CallCount); } /// /// SEC-31: the success-reset path stays reachable while throttled. A throttled partition admits /// one probe per interval; the correct secret rides that slot, authenticates, and fully clears /// both limiter layers, so the next wrong attempt is /// rather than . /// /// A task that represents the asynchronous operation. [Fact] public async Task CorrectSecret_DuringProbeMode_AuthenticatesViaProbeSlotAndResets() { ManualTimeProvider clock = new(DateTimeOffset.UnixEpoch); ApiKeyFailureLimiter limiter = CreateLimiter(clock, limit: 3, aggregateLimit: 30); GatewayGrpcAuthorizationInterceptor failing = CreateInterceptor( new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)), new GatewayRequestIdentityAccessor(), failureLimiter: limiter); FakeApiKeyVerifier holderVerifier = new(SuccessWithScopes(GatewayScopes.SessionOpen)); GatewayGrpcAuthorizationInterceptor succeeding = CreateInterceptor( holderVerifier, new GatewayRequestIdentityAccessor(), failureLimiter: limiter); for (int attempt = 0; attempt < 3; attempt++) { await Assert.ThrowsAsync( () => failing.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_bad", HolderPeer), (_, _) => Task.FromResult(new OpenSessionReply()))); } RpcException throttled = await Assert.ThrowsAsync( () => failing.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_bad", HolderPeer), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.ResourceExhausted, throttled.StatusCode); clock.Advance(ProbeInterval); OpenSessionReply reply = await succeeding.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_correct", HolderPeer), (_, _) => Task.FromResult(new OpenSessionReply { SessionId = "session-1" })); Assert.True(holderVerifier.WasCalled); Assert.Equal("session-1", reply.SessionId); RpcException afterReset = await Assert.ThrowsAsync( () => failing.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_bad", HolderPeer), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.Unauthenticated, afterReset.StatusCode); } /// /// SEC-32: only a validly shaped mxgw_<keyId>_<secret> token mints a key-id /// partition. Junk tokens of varied shapes all collapse onto the sender's transport-peer fallback /// partition, so a spray cannot mint one tracked entry per invented token. /// /// A task that represents the asynchronous operation. [Fact] public async Task NonMxgwToken_FallsBackToTransportPeerPartition() { ManualTimeProvider clock = new(DateTimeOffset.UnixEpoch); ApiKeyFailureLimiter limiter = CreateLimiter(clock, limit: 1000); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(Failure(ApiKeyFailure.MissingOrMalformed)), new GatewayRequestIdentityAccessor(), failureLimiter: limiter); string[] junkTokens = [ "Bearer garbage", "Bearer a_b_c", "Bearer notmxgw_operator01_secret", "Bearer MXGW_operator01_secret", "Bearer mxgw__secret", "Bearer mxgw_operator01_", "Bearer mxgw_" + new string('k', 65) + "_secret", "Bearer mxgw_onlytwo", ]; foreach (string token in junkTokens) { await Assert.ThrowsAsync( () => interceptor.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization(token, AttackerPeer), (_, _) => Task.FromResult(new OpenSessionReply()))); } Assert.Equal(1, limiter.TrackedPartitionCount); Assert.True(limiter.IsTracked(new ApiKeyThrottlePartition(AttackerPeer, KeyId: null))); } /// /// A successful verification resets the peer's failure counter, so accumulated failures /// from a fat-fingered secret do not lock out a client that subsequently authenticates. /// /// A task that represents the asynchronous operation. [Fact] public async Task UnaryServerHandler_SuccessResetsFailureCounter() { ApiKeyFailureLimiter limiter = CreateLimiter(new ManualTimeProvider(DateTimeOffset.UnixEpoch), limit: 3); // Two failures against the same key id, then a success (which resets), then two more // failures — without the reset the fifth attempt would be blocked at the limit of 3. GatewayGrpcAuthorizationInterceptor failing = CreateInterceptor( new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)), new GatewayRequestIdentityAccessor(), failureLimiter: limiter); GatewayGrpcAuthorizationInterceptor succeeding = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.SessionOpen)), new GatewayRequestIdentityAccessor(), failureLimiter: limiter); for (int i = 0; i < 2; i++) { await Assert.ThrowsAsync( () => failing.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_bad"), (_, _) => Task.FromResult(new OpenSessionReply()))); } await succeeding.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_good"), (_, _) => Task.FromResult(new OpenSessionReply { SessionId = "s" })); // Post-reset: two more failures still map to Unauthenticated (not ResourceExhausted). for (int i = 0; i < 2; i++) { RpcException ex = await Assert.ThrowsAsync( () => failing.UnaryServerHandler( new OpenSessionRequest(), ContextWithAuthorization("Bearer mxgw_operator01_bad"), (_, _) => Task.FromResult(new OpenSessionReply()))); Assert.Equal(StatusCode.Unauthenticated, ex.StatusCode); } } private static MxAccessGatewayService CreateService( ISessionManager sessionManager, IGatewayRequestIdentityAccessor identityAccessor) { return new MxAccessGatewayService( sessionManager, identityAccessor, new AllowAllConstraintEnforcer(), new MxAccessGrpcRequestValidator(), new MxAccessGrpcMapper(), new NoOpEventStreamService(), new GatewayMetrics(), NullLogger.Instance, new FakeGatewayAlarmService()); } private static GatewayGrpcAuthorizationInterceptor CreateInterceptor( IApiKeyVerifier apiKeyVerifier, IGatewayRequestIdentityAccessor identityAccessor, AuthenticationMode authenticationMode = AuthenticationMode.ApiKey, ApiKeyFailureLimiter? failureLimiter = null) { return new GatewayGrpcAuthorizationInterceptor( apiKeyVerifier, new GatewayGrpcScopeResolver(), identityAccessor, Options.Create(new GatewayOptions { Authentication = new AuthenticationOptions { Mode = authenticationMode } }), failureLimiter ?? CreateLimiter(TimeProvider.System, limit: 1000), new GatewayMetrics()); } private static ApiKeyFailureLimiter CreateLimiter( TimeProvider clock, int limit, int aggregateLimit = 0, int maxPartitions = 1024) { return new ApiKeyFailureLimiter( limit, FailureWindow, maxPartitions, aggregateLimit, ProbeInterval, clock); } private static ApiKeyVerification SuccessWithScopes(params string[] scopes) { return new ApiKeyVerification( Succeeded: true, Identity: new LibApiKeyIdentity( KeyId: "operator01", DisplayName: "Operator Key", Scopes: new HashSet(scopes, StringComparer.Ordinal), Constraints: null), Failure: null); } private static ApiKeyVerification Failure(ApiKeyFailure failure) { return new ApiKeyVerification(Succeeded: false, Identity: null, Failure: failure); } private static TestServerCallContext ContextWithAuthorization(string authorizationHeader, string? peer = null) { return new TestServerCallContext( [new Metadata.Entry("authorization", authorizationHeader)], peer: peer); } /// Records whether the gateway service ran past the interceptor for composition tests. private sealed class RecordingSessionManager : ISessionManager { /// Gets the number of times OpenSessionAsync was invoked. public int OpenSessionCount { get; private set; } /// Gets the number of times InvokeAsync was invoked. public int InvokeCount { get; private set; } /// Gets the last client identity passed to OpenSessionAsync. public string? LastClientIdentity { get; private set; } /// public Task OpenSessionAsync( SessionOpenRequest request, string? clientIdentity, string? ownerKeyId, CancellationToken cancellationToken) { OpenSessionCount++; LastClientIdentity = clientIdentity; GatewaySession session = new( "session-1", GatewayContractInfo.DefaultBackendName, "pipe", "nonce", clientIdentity ?? "client", "client-session", "client-correlation", TimeSpan.FromSeconds(7), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(10), DateTimeOffset.UtcNow); return Task.FromResult(session); } /// public bool TryGetSession(string sessionId, out GatewaySession session) { session = null!; return false; } /// public Task InvokeAsync( string sessionId, WorkerCommand command, CancellationToken cancellationToken) { InvokeCount++; return Task.FromResult(new WorkerCommandReply()); } /// public IAsyncEnumerable ReadEventsAsync( string sessionId, CancellationToken cancellationToken) { return AsyncEnumerable.Empty(); } /// public IAsyncEnumerable ReadAlarmEventsAsync( string sessionId, CancellationToken cancellationToken) { return AsyncEnumerable.Empty(); } /// public Task CloseSessionAsync( string sessionId, CancellationToken cancellationToken) { return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } /// public Task KillWorkerAsync( string sessionId, string reason, CancellationToken cancellationToken) { return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false)); } /// public Task CloseExpiredLeasesAsync( DateTimeOffset now, CancellationToken cancellationToken) { return Task.FromResult(0); } /// public Task ShutdownAsync(CancellationToken cancellationToken) { return Task.CompletedTask; } } /// Event stream service that yields nothing; alarm/event RPCs are not under test here. private sealed class NoOpEventStreamService : IEventStreamService { /// public async IAsyncEnumerable StreamEventsAsync( StreamEventsRequest request, string? callerKeyId, [EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield break; } } private sealed class CountingFailureVerifier(ApiKeyVerification result) : IApiKeyVerifier { /// Gets the number of times the verifier was invoked. public int CallCount { get; private set; } /// Returns the configured result and counts the invocation. /// The authorization header to verify. /// Cancellation token. /// The configured verification result. public Task VerifyAsync(string authorizationHeader, CancellationToken ct) { CallCount++; return Task.FromResult(result); } } private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier { /// Gets whether the verifier was called. public bool WasCalled { get; private set; } /// Gets the last authorization header seen by the verifier. public string? LastAuthorizationHeader { get; private set; } /// Verifies the authorization header against stored result. /// The authorization header to verify. /// Cancellation token. /// Configured verification result. public Task VerifyAsync( string authorizationHeader, CancellationToken ct) { WasCalled = true; LastAuthorizationHeader = authorizationHeader; return Task.FromResult(result); } } }