using Grpc.Core; using Microsoft.Extensions.Options; using MxGateway.Contracts.Proto; using MxGateway.Server.Configuration; using MxGateway.Server.Security.Authentication; using MxGateway.Server.Security.Authorization; namespace MxGateway.Tests.Security.Authorization; public sealed class GatewayGrpcAuthorizationInterceptorTests { [Fact] public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail( ApiKeyVerificationFailure.MissingOrMalformedCredentials)), 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); } [Fact] public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus() { GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.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); } [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); } [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); } [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 TestServerStreamWriter(), ContextWithAuthorization("Bearer mxgw_operator01_secret"), (_, _, _) => Task.CompletedTask)); Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode); Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal); } [Fact] public async Task ServerStreamingServerHandler_ValidApiKeyWithScope_AllowsStream() { GatewayRequestIdentityAccessor identityAccessor = new(); GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor( new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)), identityAccessor); TestServerStreamWriter 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); } [Fact] public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification() { GatewayRequestIdentityAccessor identityAccessor = new(); FakeApiKeyVerifier verifier = new(ApiKeyVerificationResult.Fail( ApiKeyVerificationFailure.MissingOrMalformedCredentials)); 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); } private static GatewayGrpcAuthorizationInterceptor CreateInterceptor( IApiKeyVerifier apiKeyVerifier, IGatewayRequestIdentityAccessor identityAccessor, AuthenticationMode authenticationMode = AuthenticationMode.ApiKey) { return new GatewayGrpcAuthorizationInterceptor( apiKeyVerifier, new GatewayGrpcScopeResolver(), identityAccessor, Options.Create(new GatewayOptions { Authentication = new AuthenticationOptions { Mode = authenticationMode } })); } private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes) { return ApiKeyVerificationResult.Success(new ApiKeyIdentity( KeyId: "operator01", KeyPrefix: "mxgw_operator01", DisplayName: "Operator Key", Scopes: new HashSet(scopes, StringComparer.Ordinal))); } private static TestServerCallContext ContextWithAuthorization(string authorizationHeader) { return new TestServerCallContext([new Metadata.Entry("authorization", authorizationHeader)]); } private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier { public bool WasCalled { get; private set; } public string? LastAuthorizationHeader { get; private set; } public Task VerifyAsync( string? authorizationHeader, CancellationToken cancellationToken) { WasCalled = true; LastAuthorizationHeader = authorizationHeader; return Task.FromResult(result); } } private sealed class TestServerStreamWriter : IServerStreamWriter { public List Messages { get; } = []; public WriteOptions? WriteOptions { get; set; } public Task WriteAsync(T message) { Messages.Add(message); return Task.CompletedTask; } } private sealed class TestServerCallContext( Metadata requestHeaders, CancellationToken cancellationToken = default) : ServerCallContext { private readonly Metadata responseTrailers = []; private readonly Dictionary userState = []; private Status status; private WriteOptions? writeOptions; protected override string MethodCore => "/mxaccess_gateway.v1.MxAccessGateway/Test"; protected override string HostCore => "localhost"; protected override string PeerCore => "ipv4:127.0.0.1:5000"; protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1); protected override Metadata RequestHeadersCore => requestHeaders; protected override CancellationToken CancellationTokenCore => cancellationToken; protected override Metadata ResponseTrailersCore => responseTrailers; protected override Status StatusCore { get => status; set => status = value; } protected override WriteOptions? WriteOptionsCore { get => writeOptions; set => writeOptions = value; } protected override AuthContext AuthContextCore { get; } = new( string.Empty, new Dictionary>(StringComparer.Ordinal)); protected override IDictionary UserStateCore => userState; protected override Task WriteResponseHeadersAsyncCore(Metadata responseHeaders) { return Task.CompletedTask; } protected override ContextPropagationToken CreatePropagationTokenCore( ContextPropagationOptions? options) { throw new NotSupportedException(); } } }