diff --git a/src/Grpc.Net.Client/GrpcChannel.cs b/src/Grpc.Net.Client/GrpcChannel.cs index 3efe17c13..f24f6d9be 100644 --- a/src/Grpc.Net.Client/GrpcChannel.cs +++ b/src/Grpc.Net.Client/GrpcChannel.cs @@ -70,6 +70,7 @@ public sealed class GrpcChannel : ChannelBase, IDisposable internal ILoggerFactory LoggerFactory { get; } internal ILogger Logger { get; } internal bool ThrowOperationCanceledOnCancellation { get; } + internal bool UnsafeUseInsecureChannelCallCredentials { get; } internal bool IsSecure => _isSecure; internal List? CallCredentials => _callCredentials; internal Dictionary CompressionProviders { get; } @@ -159,6 +160,7 @@ internal GrpcChannel(Uri address, GrpcChannelOptions channelOptions) : base(addr MessageAcceptEncoding = GrpcProtocolHelpers.GetMessageAcceptEncoding(CompressionProviders); Logger = LoggerFactory.CreateLogger(); ThrowOperationCanceledOnCancellation = channelOptions.ThrowOperationCanceledOnCancellation; + UnsafeUseInsecureChannelCallCredentials = channelOptions.UnsafeUseInsecureChannelCallCredentials; _createMethodInfoFunc = CreateMethodInfo; ActiveCalls = new HashSet(); if (channelOptions.ServiceConfig is { } serviceConfig) diff --git a/src/Grpc.Net.Client/GrpcChannelOptions.cs b/src/Grpc.Net.Client/GrpcChannelOptions.cs index c351dbf3a..a4839ac0a 100644 --- a/src/Grpc.Net.Client/GrpcChannelOptions.cs +++ b/src/Grpc.Net.Client/GrpcChannelOptions.cs @@ -34,13 +34,13 @@ public sealed class GrpcChannelOptions #endif /// - /// Gets or sets the credentials for the channel. This setting is used to set for + /// Gets or sets the credentials for the channel. This setting is used to set for /// a channel. Connection transport layer security (TLS) is determined by the address used to create the channel. /// /// /// /// The channel credentials you use must match the address TLS setting. Use - /// for an "http" address and with no arguments for "https". + /// for an "http" address and for "https". /// /// /// The underlying used by the channel automatically loads root certificates @@ -183,6 +183,24 @@ public sealed class GrpcChannelOptions /// public bool ThrowOperationCanceledOnCancellation { get; set; } + /// + /// Gets or sets a value indicating whether a gRPC call's are used by an insecure channel. + /// The default value is false. + /// + /// Note: Experimental API that can change or be removed without any prior notice. + /// + /// + /// + /// + /// The default value for this property is false, which causes an insecure channel to ignore a gRPC call's . + /// Sending authentication headers over an insecure connection has security implications and shouldn't be done in production environments. + /// + /// + /// If this property is set to true, call credentials are always used by a channel. + /// + /// + public bool UnsafeUseInsecureChannelCallCredentials { get; set; } + /// /// Gets or sets the service config for a gRPC channel. A service config allows service owners to publish parameters /// to be automatically used by all clients of their service. A service config can also be specified by a client diff --git a/src/Grpc.Net.Client/Internal/GrpcCall.cs b/src/Grpc.Net.Client/Internal/GrpcCall.cs index 06fa8f443..a2bbfc8ea 100644 --- a/src/Grpc.Net.Client/Internal/GrpcCall.cs +++ b/src/Grpc.Net.Client/Internal/GrpcCall.cs @@ -886,7 +886,7 @@ private async Task ReadCredentials(HttpRequestMessage request) // In C-Core the call credential auth metadata is only applied if the channel is secure // The equivalent in grpc-dotnet is only applying metadata if HttpClient is using TLS // HttpClient scheme will be HTTP if it is using H2C (HTTP2 without TLS) - if (Channel.IsSecure) + if (Channel.IsSecure || Channel.UnsafeUseInsecureChannelCallCredentials) { var configurator = new DefaultCallCredentialsConfigurator(); diff --git a/src/Grpc.Net.ClientFactory/CallOptionsContext.cs b/src/Grpc.Net.ClientFactory/CallOptionsContext.cs new file mode 100644 index 000000000..431cfcb63 --- /dev/null +++ b/src/Grpc.Net.ClientFactory/CallOptionsContext.cs @@ -0,0 +1,44 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using Grpc.Core; + +namespace Grpc.Net.ClientFactory +{ + /// + /// Context used to update for a gRPC call. + /// + public sealed class CallOptionsContext + { + internal CallOptionsContext(CallOptions callOptions, IServiceProvider serviceProvider) + { + CallOptions = callOptions; + ServiceProvider = serviceProvider; + } + + /// + /// Gets or sets the call options. + /// + public CallOptions CallOptions { get; set; } + + /// + /// Gets the service provider. + /// + public IServiceProvider ServiceProvider { get; } + } +} diff --git a/src/Grpc.Net.ClientFactory/GrpcClientFactoryOptions.cs b/src/Grpc.Net.ClientFactory/GrpcClientFactoryOptions.cs index 4e478438c..c56ee0d83 100644 --- a/src/Grpc.Net.ClientFactory/GrpcClientFactoryOptions.cs +++ b/src/Grpc.Net.ClientFactory/GrpcClientFactoryOptions.cs @@ -37,6 +37,11 @@ public class GrpcClientFactoryOptions /// public IList> ChannelOptionsActions { get; } = new List>(); + /// + /// Gets a list of operations used to configure a . + /// + public IList> CallOptionsActions { get; } = new List>(); + /// /// Gets a list of instances used to configure a gRPC client pipeline. /// diff --git a/src/Grpc.Net.ClientFactory/GrpcHttpClientBuilderExtensions.cs b/src/Grpc.Net.ClientFactory/GrpcHttpClientBuilderExtensions.cs index e1fdb30cf..1440c5117 100644 --- a/src/Grpc.Net.ClientFactory/GrpcHttpClientBuilderExtensions.cs +++ b/src/Grpc.Net.ClientFactory/GrpcHttpClientBuilderExtensions.cs @@ -129,6 +129,82 @@ public static IHttpClientBuilder AddInterceptor(this IHttpClientBuilder builder, return builder; } + /// + /// Adds delegate that will be used to create for a gRPC call. + /// + /// The . + /// A delegate that is used to create for a gRPC call. + /// An that can be used to configure the client. + public static IHttpClientBuilder AddCallCredentials(this IHttpClientBuilder builder, Func authInterceptor) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (authInterceptor == null) + { + throw new ArgumentNullException(nameof(authInterceptor)); + } + + ValidateGrpcClient(builder); + + builder.Services.Configure(builder.Name, options => + { + options.CallOptionsActions.Add((callOptionsContext) => + { + var credentials = CallCredentials.FromInterceptor((context, metadata) => authInterceptor(context, metadata)); + + callOptionsContext.CallOptions = ResolveCallOptionsCredentials(callOptionsContext.CallOptions, credentials); + }); + }); + + return builder; + } + + /// + /// Adds delegate that will be used to create for a gRPC call. + /// + /// The . + /// A delegate that is used to create for a gRPC call. + /// An that can be used to configure the client. + public static IHttpClientBuilder AddCallCredentials(this IHttpClientBuilder builder, Func authInterceptor) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (authInterceptor == null) + { + throw new ArgumentNullException(nameof(authInterceptor)); + } + + ValidateGrpcClient(builder); + + builder.Services.Configure(builder.Name, options => + { + options.CallOptionsActions.Add((callOptionsContext) => + { + var credentials = CallCredentials.FromInterceptor((context, metadata) => authInterceptor(context, metadata, callOptionsContext.ServiceProvider)); + + callOptionsContext.CallOptions = ResolveCallOptionsCredentials(callOptionsContext.CallOptions, credentials); + }); + }); + + return builder; + } + + private static CallOptions ResolveCallOptionsCredentials(CallOptions callOptions, CallCredentials credentials) + { + if (callOptions.Credentials != null) + { + credentials = CallCredentials.Compose(callOptions.Credentials, credentials); + } + + return callOptions.WithCredentials(credentials); + } + /// /// Adds a delegate that will be used to create an additional inteceptor for a gRPC client. /// The interceptor scope is . diff --git a/src/Grpc.Net.ClientFactory/Internal/CallOptionsConfigurationInvoker.cs b/src/Grpc.Net.ClientFactory/Internal/CallOptionsConfigurationInvoker.cs new file mode 100644 index 000000000..9a952c8bc --- /dev/null +++ b/src/Grpc.Net.ClientFactory/Internal/CallOptionsConfigurationInvoker.cs @@ -0,0 +1,73 @@ +#region Copyright notice and license + +// Copyright 2019 The gRPC Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#endregion + +using Grpc.Core; + +namespace Grpc.Net.ClientFactory.Internal +{ + internal sealed class CallOptionsConfigurationInvoker : CallInvoker + { + private readonly IServiceProvider _serviceProvider; + private readonly IList> _callOptionsActions; + private readonly CallInvoker _innerInvoker; + + public CallOptionsConfigurationInvoker(CallInvoker innerInvoker, IList> callOptionsActions, IServiceProvider serviceProvider) + { + _innerInvoker = innerInvoker; + _callOptionsActions = callOptionsActions; + _serviceProvider = serviceProvider; + } + + private CallOptions ResolveCallOptions(CallOptions callOptions) + { + var context = new CallOptionsContext(callOptions, _serviceProvider); + + for (var i = 0; i < _callOptionsActions.Count; i++) + { + _callOptionsActions[i](context); + } + + return context.CallOptions; + } + + public override AsyncClientStreamingCall AsyncClientStreamingCall(Method method, string? host, CallOptions options) + { + return _innerInvoker.AsyncClientStreamingCall(method, host, ResolveCallOptions(options)); + } + + public override AsyncDuplexStreamingCall AsyncDuplexStreamingCall(Method method, string? host, CallOptions options) + { + return _innerInvoker.AsyncDuplexStreamingCall(method, host, ResolveCallOptions(options)); + } + + public override AsyncServerStreamingCall AsyncServerStreamingCall(Method method, string? host, CallOptions options, TRequest request) + { + return _innerInvoker.AsyncServerStreamingCall(method, host, ResolveCallOptions(options), request); + } + + public override AsyncUnaryCall AsyncUnaryCall(Method method, string? host, CallOptions options, TRequest request) + { + return _innerInvoker.AsyncUnaryCall(method, host, ResolveCallOptions(options), request); + } + + public override TResponse BlockingUnaryCall(Method method, string? host, CallOptions options, TRequest request) + { + return _innerInvoker.BlockingUnaryCall(method, host, ResolveCallOptions(options), request); + } + } +} diff --git a/src/Grpc.Net.ClientFactory/Internal/DefaultGrpcClientFactory.cs b/src/Grpc.Net.ClientFactory/Internal/DefaultGrpcClientFactory.cs index c5d704b1d..c38312099 100644 --- a/src/Grpc.Net.ClientFactory/Internal/DefaultGrpcClientFactory.cs +++ b/src/Grpc.Net.ClientFactory/Internal/DefaultGrpcClientFactory.cs @@ -16,6 +16,7 @@ #endregion +using Grpc.Core; using Grpc.Core.Interceptors; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -62,6 +63,11 @@ public override TClient CreateClient(string name) where TClient : class } #pragma warning restore CS0618 // Type or member is obsolete + if (clientFactoryOptions.CallOptionsActions.Count != 0) + { + resolvedCallInvoker = new CallOptionsConfigurationInvoker(resolvedCallInvoker, clientFactoryOptions.CallOptionsActions, _serviceProvider); + } + if (clientFactoryOptions.Creator != null) { var c = clientFactoryOptions.Creator(resolvedCallInvoker); diff --git a/test/Grpc.Net.Client.Tests/CallCredentialTests.cs b/test/Grpc.Net.Client.Tests/CallCredentialTests.cs index 7577dd93a..1a91eec1d 100644 --- a/test/Grpc.Net.Client.Tests/CallCredentialTests.cs +++ b/test/Grpc.Net.Client.Tests/CallCredentialTests.cs @@ -127,6 +127,37 @@ public async Task CallCredentialsWithHttp_NoMetadataOnRequest() Assert.AreEqual("The configured CallCredentials were not used because the call does not use TLS.", log.State.ToString()); } + [Test] + public async Task CallCredentialsWithHttp_UnsafeUseInsecureChannelCallCredentials_MetadataOnRequest() + { + // Arrange + string? authorizationValue = null; + var httpClient = ClientTestHelpers.CreateTestClient(async request => + { + authorizationValue = request.Headers.GetValues("authorization").Single(); + + var reply = new HelloReply { Message = "Hello world" }; + var streamContent = await ClientTestHelpers.CreateResponseContent(reply).DefaultTimeout(); + return ResponseUtils.CreateResponse(HttpStatusCode.OK, streamContent); + }, new Uri("http://localhost")); + var invoker = HttpClientCallInvokerFactory.Create( + httpClient, + configure: o => o.UnsafeUseInsecureChannelCallCredentials = true); + + // Act + var callCredentials = CallCredentials.FromInterceptor(async (context, metadata) => + { + // The operation is asynchronous to ensure delegate is awaited + await Task.Delay(50); + metadata.Add("authorization", "SECRET_TOKEN"); + }); + var call = invoker.AsyncUnaryCall(ClientTestHelpers.ServiceMethod, string.Empty, new CallOptions(credentials: callCredentials), new HelloRequest()); + await call.ResponseAsync.DefaultTimeout(); + + // Assert + Assert.AreEqual("SECRET_TOKEN", authorizationValue); + } + [Test] public async Task CompositeCallCredentialsWithHttps_MetadataOnRequest() { diff --git a/test/Grpc.Net.ClientFactory.Tests/GrpcHttpClientBuilderExtensionsTests.cs b/test/Grpc.Net.ClientFactory.Tests/GrpcHttpClientBuilderExtensionsTests.cs index 06d85145a..d50b4ab52 100644 --- a/test/Grpc.Net.ClientFactory.Tests/GrpcHttpClientBuilderExtensionsTests.cs +++ b/test/Grpc.Net.ClientFactory.Tests/GrpcHttpClientBuilderExtensionsTests.cs @@ -486,6 +486,130 @@ public async Task AddInterceptorGeneric_ScopedLifetime_CreatedOncePerScope() Assert.AreEqual(1, channelInterceptorCreatedCount); } + [Test] + public async Task AddCallCredentials_ServiceProvider_RunInScope() + { + // Arrange + var scopeCount = 0; + var authHeaderValues = new List(); + + var services = new ServiceCollection(); + services + .AddScoped(s => new AuthProvider((scopeCount++).ToString())) + .AddGrpcClient(o => + { + o.Address = new Uri("https://localhost"); + }) + .AddCallCredentials(async (context, metadata, serviceProvider) => + { + var authProvider = serviceProvider.GetRequiredService(); + metadata.Add("authorize", await authProvider.GetTokenAsync()); + }) + .ConfigurePrimaryHttpMessageHandler(() => new TestHttpMessageHandler(request => + { + if (request.Headers.TryGetValues("authorize", out var values)) + { + authHeaderValues.AddRange(values); + } + })); + + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + + // Act + using (var scope = serviceProvider.CreateScope()) + { + var clientFactory = scope.ServiceProvider.GetRequiredService(); + var client = clientFactory.CreateClient(nameof(Greeter.GreeterClient)); + + var response1 = await client.SayHelloAsync(new HelloRequest()).ResponseAsync.DefaultTimeout(); + var response2 = await client.SayHelloAsync(new HelloRequest()).ResponseAsync.DefaultTimeout(); + + // Assert + Assert.IsNotNull(response1); + Assert.IsNotNull(response2); + Assert.AreEqual(2, authHeaderValues.Count); + Assert.AreEqual("0", authHeaderValues[0]); + Assert.AreEqual("0", authHeaderValues[1]); + } + + authHeaderValues.Clear(); + + // Act + using (var scope = serviceProvider.CreateScope()) + { + var clientFactory = scope.ServiceProvider.GetRequiredService(); + var client = clientFactory.CreateClient(nameof(Greeter.GreeterClient)); + + var response1 = await client.SayHelloAsync(new HelloRequest()).ResponseAsync.DefaultTimeout(); + var response2 = await client.SayHelloAsync(new HelloRequest()).ResponseAsync.DefaultTimeout(); + + // Assert + Assert.IsNotNull(response1); + Assert.IsNotNull(response2); + Assert.AreEqual(2, authHeaderValues.Count); + Assert.AreEqual("1", authHeaderValues[0]); + Assert.AreEqual("1", authHeaderValues[1]); + } + + // Only one channel and its interceptor is created for multiple scopes. + Assert.AreEqual(2, scopeCount); + } + + [Test] + public async Task AddCallCredentials_PassedInCallCredentials_Combine() + { + // Arrange + HttpRequestMessage? sentRequest = null; + + var services = new ServiceCollection(); + services + .AddGrpcClient(o => + { + o.Address = new Uri("https://localhost"); + }) + .AddCallCredentials((context, metadata) => + { + metadata.Add("factory-authorize", "auth!"); + return Task.CompletedTask; + }) + .ConfigurePrimaryHttpMessageHandler(() => new TestHttpMessageHandler(request => + { + sentRequest = request; + })); + + var serviceProvider = services.BuildServiceProvider(validateScopes: true); + + // Act + var clientFactory = serviceProvider.GetRequiredService(); + var client = clientFactory.CreateClient(nameof(Greeter.GreeterClient)); + + var response = await client.SayHelloAsync( + new HelloRequest(), + new CallOptions(credentials: CallCredentials.FromInterceptor((context, metadata) => + { + metadata.Add("call-authorize", "auth!"); + return Task.CompletedTask; + }))).ResponseAsync.DefaultTimeout(); + + // Assert + Assert.NotNull(response); + + Assert.AreEqual("auth!", sentRequest!.Headers.GetValues("factory-authorize").Single()); + Assert.AreEqual("auth!", sentRequest!.Headers.GetValues("call-authorize").Single()); + } + + private class AuthProvider + { + private readonly string _headerValue; + + public AuthProvider(string headerValue) + { + _headerValue = headerValue; + } + + public Task GetTokenAsync() => Task.FromResult(_headerValue); + } + private class DerivedGreeterClient : Greeter.GreeterClient { public DerivedGreeterClient(CallInvoker callInvoker) : base(callInvoker) @@ -497,9 +621,17 @@ private class TestHttpMessageHandler : HttpMessageHandler { public bool Invoked { get; private set; } + private Action? _requestCallback; + + public TestHttpMessageHandler(Action? requestCallback = null) + { + _requestCallback = requestCallback; + } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { Invoked = true; + _requestCallback?.Invoke(request); // Get stream from request content so gRPC client serializes request message #if NET5_0_OR_GREATER