sources/Google.Solutions.Apis/Client/PscAndMtlsAwareHttpClientFactory.cs (175 lines of code) (raw):

// // Copyright 2023 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you 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. // using Google.Apis.Auth.OAuth2; using Google.Apis.Http; using Google.Solutions.Apis.Auth; using Google.Solutions.Common.Diagnostics; using Google.Solutions.Common.Util; using System.Diagnostics; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; namespace Google.Solutions.Apis.Client { /// <summary> /// Client factory that enables client certificate and adds /// PSC-style Host headers if needed. /// </summary> public class PscAndMtlsAwareHttpClientFactory : IHttpClientFactory, IHttpExecuteInterceptor, IHttpUnsuccessfulResponseHandler { private readonly ServiceEndpointDirections directions; private readonly IDeviceEnrollment deviceEnrollment; private readonly ICredential? credential; private readonly UserAgent userAgent; /// <summary> /// Number of retries to perform if NTLM proxy authentication fails. /// </summary> public static ushort NtlmProxyAuthenticationRetries { get; set; } = 1; internal PscAndMtlsAwareHttpClientFactory( ServiceEndpointDirections directions, IAuthorization authorization, UserAgent userAgent) { this.directions = directions; this.deviceEnrollment = authorization.DeviceEnrollment.ExpectNotNull(nameof(this.deviceEnrollment)); this.credential = authorization.Session.ApiCredential; this.userAgent = userAgent.ExpectNotNull(nameof(userAgent)); } internal PscAndMtlsAwareHttpClientFactory( ServiceEndpointDirections directions, IDeviceEnrollment deviceEnrollment, UserAgent userAgent) { this.directions = directions; this.deviceEnrollment = deviceEnrollment.ExpectNotNull(nameof(deviceEnrollment)); this.credential = null; this.userAgent = userAgent.ExpectNotNull(nameof(userAgent)); } public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) { if (this.credential != null) { args.Initializers.Add(this.credential); } args.ApplicationName = this.userAgent.ToApplicationName(); var factory = new MtlsAwareHttpClientFactory(this.directions, this.deviceEnrollment); var httpClient = factory.CreateHttpClient(args); httpClient.MessageHandler.AddExecuteInterceptor(this); httpClient.MessageHandler.AddUnsuccessfulResponseHandler(this); return httpClient; } public Task InterceptAsync( HttpRequestMessage request, CancellationToken cancellationToken) { if (this.directions.Type == ServiceEndpointType.PrivateServiceConnect) { Debug.Assert(!string.IsNullOrEmpty(this.directions.Host)); // // We're using PSC, the so hostname we're using to connect is // different than what the server expects. // Debug.Assert(request.RequestUri.Host != this.directions.Host); // // Inject the normal hostname so that certificate validation works. // request.Headers.Host = this.directions.Host; } ApiEventSource.Log.HttpRequestInitiated( request.Method.ToString(), request.RequestUri.ToString(), this.directions.Type.ToString(), this.directions.Host); return Task.CompletedTask; } public Task<bool> HandleResponseAsync(HandleUnsuccessfulResponseArgs args) { ApiEventSource.Log.HttpRequestFailed( args.Request.Method.ToString(), args.Request.RequestUri.ToString(), (int)args.Response.StatusCode); return Task.FromResult(false); } /// <summary> /// Client factory that enables client certificate authenticateion /// if the device is enrolled. /// </summary> private class MtlsAwareHttpClientFactory : HttpClientFactory { private readonly ServiceEndpointDirections directions; private readonly IDeviceEnrollment deviceEnrollment; public MtlsAwareHttpClientFactory( ServiceEndpointDirections directions, IDeviceEnrollment deviceEnrollment) { this.directions = directions; this.deviceEnrollment = deviceEnrollment.ExpectNotNull(nameof(deviceEnrollment)); } protected override HttpClientHandler CreateClientHandler() { var handler = new NtlmResilientWebRequestHandler(NtlmProxyAuthenticationRetries) { Proxy = WebRequest.DefaultWebProxy, }; // // Bypass proxy for accessing PSC endpoint. // if (this.directions.Type == ServiceEndpointType.PrivateServiceConnect) { handler.UseProxy = false; ApiTraceSource.Log.TraceVerbose( "Bypassing proxy for for endpoint {0} (Host:{1})", this.directions.BaseUri, this.directions.Host); } if (this.directions.UseClientCertificate && this.deviceEnrollment.Certificate != null) { Debug.Assert(this.deviceEnrollment.State == DeviceEnrollmentState.Enrolled); handler.ClientCertificates.Add(this.deviceEnrollment.Certificate); ApiTraceSource.Log.TraceVerbose( "Using client certificate {0} for endpoint {1} (Host:{2})", this.deviceEnrollment.Certificate, this.directions.BaseUri, this.directions.Host); } return handler; } } private class NtlmResilientWebRequestHandler : WebRequestHandler { private readonly ushort maxRetries; public NtlmResilientWebRequestHandler(ushort maxRetries) { this.maxRetries = maxRetries; } protected override async Task<HttpResponseMessage> SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { // // The System.Net stack supports NTLM proxy authentication and // works reliably when requests are submitted sequentially. // However, when we're sending multiple requests in parallel, // authentication occasionally fails with SEC_E_INVALID_TOKEN in // NTAuthentication.GetOutgoingBlob. // // This error seems to be caused by a race condition in the BCL, // but there's little we can do to avoid it. // // When we see a 407 error with an NTLM challenge-header, we // are either (a) hitting the aforementioned issue or (b), // our credentials were simply invalid. // // If it's (a), there's a good chance that a retry helps. If it's // (b), a retry at least won't hurt. // for (var retry = 0; ; retry++) { try { return await base .SendAsync(request, cancellationToken) .ConfigureAwait(false); } catch (HttpRequestException e) when ( e.InnerException is WebException webException && webException.Response is HttpWebResponse webResponse && this.UseProxy && this.Proxy?.Credentials != null && IsNtlmProxyAuthenticationRequiredResponse(webResponse)) { var message = e.FullMessage(); ApiTraceSource.Log.TraceWarning( "NTLM proxy authentication failed (attempt {0}/{1})): {2}", retry, this.maxRetries, message); ApiEventSource.Log.HttpNtlmProxyRequestFailed( webResponse.ResponseUri.AbsoluteUri, retry, message); if (retry < this.maxRetries) { // // Retry request. // } else { // // It's not worth retrying. // throw; } } } } private static bool IsNtlmProxyAuthenticationRequiredResponse(HttpWebResponse response) { return response.StatusCode == HttpStatusCode.ProxyAuthenticationRequired && response.Headers.Get("Proxy-Authenticate") is var proxyAuthHeader && proxyAuthHeader != null && proxyAuthHeader.StartsWith("NTLM "); } } } }