diff --git a/src/Common/tests/System/Net/Http/LoopbackServer.AuthenticationHelpers.cs b/src/Common/tests/System/Net/Http/LoopbackServer.AuthenticationHelpers.cs new file mode 100644 index 000000000000..70c5a4888dfd --- /dev/null +++ b/src/Common/tests/System/Net/Http/LoopbackServer.AuthenticationHelpers.cs @@ -0,0 +1,299 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; + +namespace System.Net.Test.Common +{ + public sealed partial class LoopbackServer + { + internal enum AuthenticationProtocols + { + Basic, + Digest, + None + } + + public async Task> AcceptConnectionPerformAuthenticationAndCloseAsync(string authenticateHeaders) + { + List lines = null; + await AcceptConnectionAsync(async connection => + { + await connection.ReadRequestHeaderAndSendResponseAsync(HttpStatusCode.Unauthorized, authenticateHeaders); + + lines = await connection.ReadRequestHeaderAsync(); + Debug.Assert(lines.Count > 0); + + int index = lines[0] != null ? lines[0].IndexOf(' ') : -1; + string requestMethod = null; + if (index != -1) + { + requestMethod = lines[0].Substring(0, index); + } + + // Read the authorization header from client. + AuthenticationProtocols protocol = AuthenticationProtocols.None; + string clientResponse = null; + for (int i = 1; i < lines.Count; i++) + { + if (lines[i].StartsWith("Authorization")) + { + clientResponse = lines[i]; + if (lines[i].Contains(nameof(AuthenticationProtocols.Basic))) + { + protocol = AuthenticationProtocols.Basic; + break; + } + else if (lines[i].Contains(nameof(AuthenticationProtocols.Digest))) + { + protocol = AuthenticationProtocols.Digest; + break; + } + } + } + + bool success = false; + switch (protocol) + { + case AuthenticationProtocols.Basic: + success = IsBasicAuthTokenValid(clientResponse, _options); + break; + + case AuthenticationProtocols.Digest: + // Read the request content. + success = IsDigestAuthTokenValid(clientResponse, requestMethod, _options); + break; + } + + if (success) + { + await connection.SendResponseAsync(); + } + else + { + await connection.SendResponseAsync(HttpStatusCode.Unauthorized, authenticateHeaders); + } + }); + + return lines; + } + + internal static bool IsBasicAuthTokenValid(string clientResponse, LoopbackServer.Options options) + { + string clientHash = clientResponse.Substring(clientResponse.IndexOf(nameof(AuthenticationProtocols.Basic), StringComparison.OrdinalIgnoreCase) + + nameof(AuthenticationProtocols.Basic).Length).Trim(); + string userPass = string.IsNullOrEmpty(options.Domain) ? options.Username + ":" + options.Password : options.Domain + "\\" + options.Username + ":" + options.Password; + return clientHash == Convert.ToBase64String(Encoding.UTF8.GetBytes(userPass)); + } + + internal static bool IsDigestAuthTokenValid(string clientResponse, string requestMethod, LoopbackServer.Options options) + { + string clientHash = clientResponse.Substring(clientResponse.IndexOf(nameof(AuthenticationProtocols.Digest), StringComparison.OrdinalIgnoreCase) + + nameof(AuthenticationProtocols.Digest).Length).Trim(); + string[] values = clientHash.Split(','); + + string username = null, uri = null, realm = null, nonce = null, response = null, algorithm = null, cnonce = null, opaque = null, qop = null, nc = null; + bool userhash = false; + for (int i = 0; i < values.Length; i++) + { + string trimmedValue = values[i].Trim(); + if (trimmedValue.Contains(nameof(username))) + { + // Username is a quoted string. + int startIndex = trimmedValue.IndexOf('"'); + + if (startIndex != -1) + { + startIndex += 1; + username = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex - 1); + } + + // Username is mandatory. + if (string.IsNullOrEmpty(username)) + return false; + } + else if (trimmedValue.Contains(nameof(userhash)) && trimmedValue.Contains("true")) + { + userhash = true; + } + else if (trimmedValue.Contains(nameof(uri))) + { + int startIndex = trimmedValue.IndexOf('"'); + if (startIndex != -1) + { + startIndex += 1; + uri = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex - 1); + } + + // Request uri is mandatory. + if (string.IsNullOrEmpty(uri)) + return false; + } + else if (trimmedValue.Contains(nameof(realm))) + { + // Realm is a quoted string. + int startIndex = trimmedValue.IndexOf('"'); + if (startIndex != -1) + { + startIndex += 1; + realm = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex - 1); + } + + // Realm is mandatory. + if (string.IsNullOrEmpty(realm)) + return false; + } + else if (trimmedValue.Contains(nameof(cnonce))) + { + // CNonce is a quoted string. + int startIndex = trimmedValue.IndexOf('"'); + if (startIndex != -1) + { + startIndex += 1; + cnonce = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex - 1); + } + } + else if (trimmedValue.Contains(nameof(nonce))) + { + // Nonce is a quoted string. + int startIndex = trimmedValue.IndexOf('"'); + if (startIndex != -1) + { + startIndex += 1; + nonce = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex - 1); + } + + // Nonce is mandatory. + if (string.IsNullOrEmpty(nonce)) + return false; + } + else if (trimmedValue.Contains(nameof(response))) + { + // response is a quoted string. + int startIndex = trimmedValue.IndexOf('"'); + if (startIndex != -1) + { + startIndex += 1; + response = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex - 1); + } + + // Response is mandatory. + if (string.IsNullOrEmpty(response)) + return false; + } + else if (trimmedValue.Contains(nameof(algorithm))) + { + int startIndex = trimmedValue.IndexOf('='); + if (startIndex != -1) + { + startIndex += 1; + algorithm = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex).Trim(); + } + } + else if (trimmedValue.Contains(nameof(opaque))) + { + // Opaque is a quoted string. + int startIndex = trimmedValue.IndexOf('"'); + if (startIndex != -1) + { + startIndex += 1; + opaque = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex - 1); + } + } + else if (trimmedValue.Contains(nameof(qop))) + { + int startIndex = trimmedValue.IndexOf('"'); + if (startIndex != -1) + { + startIndex += 1; + qop = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex - 1); + } + else if ((startIndex = trimmedValue.IndexOf('=')) != -1) + { + startIndex += 1; + qop = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex).Trim(); + } + } + else if (trimmedValue.Contains(nameof(nc))) + { + int startIndex = trimmedValue.IndexOf('='); + if (startIndex != -1) + { + startIndex += 1; + nc = trimmedValue.Substring(startIndex, trimmedValue.Length - startIndex).Trim(); + } + } + } + + // Verify username. + if (userhash && ComputeHash(options.Username + ":" + realm, algorithm) != username) + { + return false; + } + + if (!userhash && options.Username != username) + { + return false; + } + + if (string.IsNullOrEmpty(algorithm)) + algorithm = "sha-256"; + + // Calculate response and compare with the client response hash. + string a1 = options.Username + ":" + realm + ":" + options.Password; + if (algorithm.Contains("sess")) + { + a1 = ComputeHash(a1, algorithm) + ":" + nonce; + + if (cnonce != null) + a1 += ":" + cnonce; + } + + string a2 = requestMethod + ":" + uri; + if (!string.IsNullOrEmpty(qop) && qop.Equals("auth-int")) + { + // Request content is empty. + a2 = a2 + ":" + ComputeHash(string.Empty, algorithm); + } + + string serverResponseHash = ComputeHash(a1, algorithm) + ":" + nonce + ":"; + + if (nc != null) + serverResponseHash += nc + ":"; + + if (cnonce != null) + serverResponseHash += cnonce + ":"; + + if (qop != null) + serverResponseHash += qop + ":"; + + serverResponseHash += ComputeHash(a2, algorithm); + serverResponseHash = ComputeHash(serverResponseHash, algorithm); + + return response == serverResponseHash; + } + + private static string ComputeHash(string data, string algorithm) + { + // Disable MD5 insecure warning. +#pragma warning disable CA5351 + using (HashAlgorithm hash = algorithm.Contains("SHA-256") ? SHA256.Create() : (HashAlgorithm)MD5.Create()) +#pragma warning restore CA5351 + { + Encoding enc = Encoding.UTF8; + byte[] result = hash.ComputeHash(enc.GetBytes(data)); + + StringBuilder sb = new StringBuilder(result.Length * 2); + foreach (byte b in result) + sb.Append(b.ToString("x2")); + + return sb.ToString(); + } + } + } +} diff --git a/src/Common/tests/System/Net/Http/LoopbackServer.cs b/src/Common/tests/System/Net/Http/LoopbackServer.cs index a417fbca1039..5acc589c185d 100644 --- a/src/Common/tests/System/Net/Http/LoopbackServer.cs +++ b/src/Common/tests/System/Net/Http/LoopbackServer.cs @@ -3,16 +3,18 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; +using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; namespace System.Net.Test.Common { - public sealed class LoopbackServer : IDisposable + public sealed partial class LoopbackServer : IDisposable { private Socket _listenSocket; private Options _options; @@ -90,7 +92,8 @@ public async Task AcceptConnectionAsync(Func funcAsync) Stream stream = new NetworkStream(s, ownsSocket: false); if (_options.UseSsl) { - var sslStream = new SslStream(stream, false, delegate { return true; }); + var sslStream = new SslStream(stream, false, delegate + { return true; }); using (var cert = Configuration.Certificates.GetServerCertificate()) { await sslStream.AuthenticateAsServerAsync( @@ -147,57 +150,104 @@ private static string GetStatusDescription(HttpStatusCode code) { switch ((int)code) { - case 100: return "Continue"; - case 101: return "Switching Protocols"; - case 102: return "Processing"; - - case 200: return "OK"; - case 201: return "Created"; - case 202: return "Accepted"; - case 203: return "Non-Authoritative Information"; - case 204: return "No Content"; - case 205: return "Reset Content"; - case 206: return "Partial Content"; - case 207: return "Multi-Status"; - - case 300: return "Multiple Choices"; - case 301: return "Moved Permanently"; - case 302: return "Found"; - case 303: return "See Other"; - case 304: return "Not Modified"; - case 305: return "Use Proxy"; - case 307: return "Temporary Redirect"; - - case 400: return "Bad Request"; - case 401: return "Unauthorized"; - case 402: return "Payment Required"; - case 403: return "Forbidden"; - case 404: return "Not Found"; - case 405: return "Method Not Allowed"; - case 406: return "Not Acceptable"; - case 407: return "Proxy Authentication Required"; - case 408: return "Request Timeout"; - case 409: return "Conflict"; - case 410: return "Gone"; - case 411: return "Length Required"; - case 412: return "Precondition Failed"; - case 413: return "Request Entity Too Large"; - case 414: return "Request-Uri Too Long"; - case 415: return "Unsupported Media Type"; - case 416: return "Requested Range Not Satisfiable"; - case 417: return "Expectation Failed"; - case 422: return "Unprocessable Entity"; - case 423: return "Locked"; - case 424: return "Failed Dependency"; - case 426: return "Upgrade Required"; // RFC 2817 - - case 500: return "Internal Server Error"; - case 501: return "Not Implemented"; - case 502: return "Bad Gateway"; - case 503: return "Service Unavailable"; - case 504: return "Gateway Timeout"; - case 505: return "Http Version Not Supported"; - case 507: return "Insufficient Storage"; + case 100: + return "Continue"; + case 101: + return "Switching Protocols"; + case 102: + return "Processing"; + + case 200: + return "OK"; + case 201: + return "Created"; + case 202: + return "Accepted"; + case 203: + return "Non-Authoritative Information"; + case 204: + return "No Content"; + case 205: + return "Reset Content"; + case 206: + return "Partial Content"; + case 207: + return "Multi-Status"; + + case 300: + return "Multiple Choices"; + case 301: + return "Moved Permanently"; + case 302: + return "Found"; + case 303: + return "See Other"; + case 304: + return "Not Modified"; + case 305: + return "Use Proxy"; + case 307: + return "Temporary Redirect"; + + case 400: + return "Bad Request"; + case 401: + return "Unauthorized"; + case 402: + return "Payment Required"; + case 403: + return "Forbidden"; + case 404: + return "Not Found"; + case 405: + return "Method Not Allowed"; + case 406: + return "Not Acceptable"; + case 407: + return "Proxy Authentication Required"; + case 408: + return "Request Timeout"; + case 409: + return "Conflict"; + case 410: + return "Gone"; + case 411: + return "Length Required"; + case 412: + return "Precondition Failed"; + case 413: + return "Request Entity Too Large"; + case 414: + return "Request-Uri Too Long"; + case 415: + return "Unsupported Media Type"; + case 416: + return "Requested Range Not Satisfiable"; + case 417: + return "Expectation Failed"; + case 422: + return "Unprocessable Entity"; + case 423: + return "Locked"; + case 424: + return "Failed Dependency"; + case 426: + return "Upgrade Required"; // RFC 2817 + + case 500: + return "Internal Server Error"; + case 501: + return "Not Implemented"; + case 502: + return "Bad Gateway"; + case 503: + return "Service Unavailable"; + case 504: + return "Gateway Timeout"; + case 505: + return "Http Version Not Supported"; + case 507: + return "Insufficient Storage"; } return null; } @@ -205,9 +255,9 @@ private static string GetStatusDescription(HttpStatusCode code) public static string GetHttpResponse(HttpStatusCode statusCode = HttpStatusCode.OK, string additionalHeaders = null, string content = null) => $"HTTP/1.1 {(int)statusCode} {GetStatusDescription(statusCode)}\r\n" + $"Date: {DateTimeOffset.UtcNow:R}\r\n" + - $"Content-Length: {(content == null ? 0 : content.Length)}\r\n" + + $"Content-Length: {(content == null ? 0 : content.Length)}\r\n" + additionalHeaders + - "\r\n" + + "\r\n" + content; public class Options @@ -218,6 +268,9 @@ public class Options public SslProtocols SslProtocols { get; set; } = SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12; public bool WebSocketEndpoint { get; set; } = false; public Func StreamWrapper { get; set; } + public string Username { get; set; } + public string Domain { get; set; } + public string Password { get; set; } } public sealed class Connection : IDisposable diff --git a/src/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.Digest.cs b/src/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.Digest.cs index 6b1c64b82aef..5c3c8fe33941 100644 --- a/src/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.Digest.cs +++ b/src/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/AuthenticationHelper.Digest.cs @@ -161,9 +161,8 @@ public static async Task GetDigestTokenForCredential(NetworkCredential c // Calculate response string a1 = credential.UserName + ":" + realm + ":" + credential.Password; - if (algorithm == Sha256Sess || algorithm == MD5Sess) + if (algorithm.IndexOf("sess") != -1) { - algorithm = algorithm == Sha256Sess ? Sha256 : Md5; a1 = ComputeHash(a1, algorithm) + ":" + nonce + ":" + cnonce; } @@ -233,7 +232,7 @@ private static string ComputeHash(string data, string algorithm) { // Disable MD5 insecure warning. #pragma warning disable CA5351 - using (HashAlgorithm hash = algorithm == Sha256 ? SHA256.Create() : (HashAlgorithm)MD5.Create()) + using (HashAlgorithm hash = algorithm.Contains(Sha256) ? SHA256.Create() : (HashAlgorithm)MD5.Create()) #pragma warning restore CA5351 { Span result = stackalloc byte[hash.HashSize / 8]; // HashSize is in bits @@ -261,7 +260,8 @@ internal class DigestResponse internal DigestResponse(string challenge) { - Parse(challenge); + if (!string.IsNullOrEmpty(challenge)) + Parse(challenge); } private static bool CharIsSpaceOrTab(char ch) @@ -269,6 +269,13 @@ private static bool CharIsSpaceOrTab(char ch) return ch == ' ' || ch == '\t'; } + private static bool MustValueBeQuoted(string key) + { + // As per the RFC, these string must be quoted for historical reasons. + return key.Equals(Realm, StringComparison.OrdinalIgnoreCase) || key.Equals(Nonce, StringComparison.OrdinalIgnoreCase) || + key.Equals(Opaque, StringComparison.OrdinalIgnoreCase) || key.Equals(Qop, StringComparison.OrdinalIgnoreCase); + } + private string GetNextKey(string data, int currentIndex, out int parsedIndex) { // Skip leading space or tab. @@ -324,7 +331,7 @@ private string GetNextKey(string data, int currentIndex, out int parsedIndex) return data.Substring(start, length); } - private string GetNextValue(string data, int currentIndex, out int parsedIndex) + private string GetNextValue(string data, int currentIndex, bool expectQuotes, out int parsedIndex) { Debug.Assert(currentIndex < data.Length && !CharIsSpaceOrTab(data[currentIndex])); @@ -336,6 +343,12 @@ private string GetNextValue(string data, int currentIndex, out int parsedIndex) currentIndex++; } + if (expectQuotes && !quotedValue) + { + parsedIndex = currentIndex; + return null; + } + StringBuilder sb = StringBuilderCache.Acquire(); while (currentIndex < data.Length && ((quotedValue && data[currentIndex] != '"') || (!quotedValue && data[currentIndex] != ','))) { @@ -356,6 +369,14 @@ private string GetNextValue(string data, int currentIndex, out int parsedIndex) } } + // Skip the quote. + if (quotedValue) + currentIndex++; + + // Skip any whitespace. + while (currentIndex < data.Length && CharIsSpaceOrTab(data[currentIndex])) + currentIndex++; + // Return if this is last value. if (currentIndex == data.Length) { @@ -363,11 +384,15 @@ private string GetNextValue(string data, int currentIndex, out int parsedIndex) return StringBuilderCache.GetStringAndRelease(sb); } - // Skip the end quote or ',' or space or tab. - currentIndex++; + // A key-value pair should end with ',' + if (data[currentIndex++] != ',') + { + parsedIndex = currentIndex; + return null; + } - // Skip space and tab and , - while (currentIndex < data.Length && (CharIsSpaceOrTab(data[currentIndex]) || data[currentIndex] == ',')) + // Skip space and tab + while (currentIndex < data.Length && CharIsSpaceOrTab(data[currentIndex])) { currentIndex++; } @@ -389,7 +414,11 @@ private unsafe void Parse(string challenge) break; // Get the value. - string value = GetNextValue(challenge, parsedIndex, out parsedIndex); + string value = GetNextValue(challenge, parsedIndex, MustValueBeQuoted(key), out parsedIndex); + // Ensure value is valid. + if (string.IsNullOrEmpty(value)) + break; + // Add the key-value pair to Parameters. Parameters.Add(key, value); } diff --git a/src/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Authentication.cs b/src/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Authentication.cs new file mode 100644 index 000000000000..cf8cf7213fa0 --- /dev/null +++ b/src/System.Net.Http/tests/FunctionalTests/HttpClientHandlerTest.Authentication.cs @@ -0,0 +1,133 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Net.Test.Common; +using System.Text; +using System.Threading.Tasks; + +using Xunit; + +namespace System.Net.Http.Functional.Tests +{ + public class HttpClientHandler_Authentication_Test : HttpClientTestBase + { + private const string Username = "testusername"; + private const string Password = "testpassword"; + private const string Domain = "testdomain"; + + private NetworkCredential _credentials = new NetworkCredential(Username, Password, Domain); + + private Func _createAndValidateRequest = async (handler, url, expectedStatusCode, credentials) => + { + handler.Credentials = credentials; + + using (HttpClient client = new HttpClient(handler)) + using (HttpResponseMessage response = await client.GetAsync(url)) + { + Assert.Equal(expectedStatusCode, response.StatusCode); + } + }; + + [Theory] + [MemberData(nameof(Authentication_TestData))] + public async Task HttpClientHandler_Authentication_Succeeds(string authenticateHeader, bool result) + { + if (PlatformDetection.IsWindowsNanoServer || (IsCurlHandler && authenticateHeader.Contains("Digest"))) + { + // TODO: #27113: Fix failing authentication test cases on different httpclienthandlers. + return; + } + + var options = new LoopbackServer.Options { Domain = Domain, Username = Username, Password = Password }; + await LoopbackServer.CreateServerAsync(async (server, url) => + { + string serverAuthenticateHeader = $"WWW-Authenticate: {authenticateHeader}\r\n"; + HttpClientHandler handler = CreateHttpClientHandler(); + Task serverTask = result ? + server.AcceptConnectionPerformAuthenticationAndCloseAsync(serverAuthenticateHeader) : + server.AcceptConnectionSendResponseAndCloseAsync(HttpStatusCode.Unauthorized, serverAuthenticateHeader); + + await TestHelper.WhenAllCompletedOrAnyFailedWithTimeout(TestHelper.PassingTestTimeoutMilliseconds, + _createAndValidateRequest(handler, url, result ? HttpStatusCode.OK : HttpStatusCode.Unauthorized, _credentials), serverTask); + }, options); + } + + [Theory] + [InlineData("WWW-Authenticate: Basic realm=\"hello1\"\r\nWWW-Authenticate: Basic realm=\"hello2\"\r\n")] + [InlineData("WWW-Authenticate: Basic realm=\"hello\"\r\nWWW-Authenticate: Basic realm=\"hello\"\r\n")] + [InlineData("WWW-Authenticate: Digest realm=\"hello\", nonce=\"hello\", algorithm=MD5\r\nWWW-Authenticate: Digest realm=\"hello\", nonce=\"hello\", algorithm=MD5\r\n")] + [InlineData("WWW-Authenticate: Digest realm=\"hello1\", nonce=\"hello\", algorithm=MD5\r\nWWW-Authenticate: Digest realm=\"hello\", nonce=\"hello\", algorithm=MD5\r\n")] + public async void HttpClientHandler_MultipleAuthenticateHeaders_WithSameAuth_Succeeds(string authenticateHeader) + { + if (IsWinHttpHandler) + { + // TODO: #27113: Fix failing authentication test cases on different httpclienthandlers. + return; + } + + await HttpClientHandler_MultipleAuthenticateHeaders_Succeeds(authenticateHeader); + } + + [Theory] + [InlineData("WWW-Authenticate: Basic realm=\"hello\"\r\nWWW-Authenticate: Digest realm=\"hello\", nonce=\"hello\", algorithm=MD5\r\n")] + [InlineData("WWW-Authenticate: Digest realm=\"hello\", nonce=\"hello\", algorithm=MD5\r\nWWW-Authenticate: Basic realm=\"hello\"\r\n")] + public async Task HttpClientHandler_MultipleAuthenticateHeaders_Succeeds(string authenticateHeader) + { + if (PlatformDetection.IsWindowsNanoServer || (IsCurlHandler && authenticateHeader.Contains("Digest"))) + { + // TODO: #27113: Fix failing authentication test cases on different httpclienthandlers. + return; + } + + var options = new LoopbackServer.Options { Domain = Domain, Username = Username, Password = Password }; + await LoopbackServer.CreateServerAsync(async (server, url) => + { + HttpClientHandler handler = CreateHttpClientHandler(); + Task serverTask = server.AcceptConnectionPerformAuthenticationAndCloseAsync(authenticateHeader); + await TestHelper.WhenAllCompletedOrAnyFailed(_createAndValidateRequest(handler, url, HttpStatusCode.OK, _credentials), serverTask); + }, options); + } + + [Theory] + [InlineData("WWW-Authenticate: Basic realm=\"hello\"\r\n")] + [InlineData("WWW-Authenticate: Digest realm=\"hello\", nonce=\"testnonce\"\r\n")] + public async void HttpClientHandler_IncorrectCredentials_Fails(string authenticateHeader) + { + var options = new LoopbackServer.Options { Domain = Domain, Username = Username, Password = Password }; + await LoopbackServer.CreateServerAsync(async (server, url) => + { + HttpClientHandler handler = CreateHttpClientHandler(); + Task serverTask = server.AcceptConnectionPerformAuthenticationAndCloseAsync(authenticateHeader); + await TestHelper.WhenAllCompletedOrAnyFailed(_createAndValidateRequest(handler, url, HttpStatusCode.Unauthorized, new NetworkCredential("wronguser", "wrongpassword")), serverTask); + }, options); + } + + public static IEnumerable Authentication_TestData() + { + yield return new object[] { "Basic realm=\"testrealm\"", true }; + yield return new object[] { "Basic ", true }; + yield return new object[] { "Basic realm=withoutquotes", true }; + + // Add digest tests fail on CurlHandler. + // TODO: #27113: Fix failing authentication test cases on different httpclienthandlers. + yield return new object[] { "Digest realm=\"testrealm\" nonce=\"testnonce\"", false }; + yield return new object[] { $"Digest realm=\"testrealm\", nonce=\"{Convert.ToBase64String(Encoding.UTF8.GetBytes($"{DateTimeOffset.UtcNow}:XMh;z+$5|`i6Hx}}\", qop=auth-int, algorithm=MD5"))}\"", true }; + yield return new object[] { "Digest realm=\"api@example.org\", qop=\"auth\", algorithm=MD5-sess, nonce=\"5TsQWLVdgBdmrQ0XsxbDODV+57QdFR34I9HAbC/RVvkK\", " + + "opaque=\"HRPCssKJSGjCrkzDg8OhwpzCiGPChXYjwrI2QmXDnsOS\", charset=UTF-8, userhash=true", true }; + yield return new object[] { $"Basic realm=\"testrealm\", " + + $"Digest realm=\"testrealm\", nonce=\"{Convert.ToBase64String(Encoding.UTF8.GetBytes($"{DateTimeOffset.UtcNow}:XMh;z+$5|`i6Hx}}"))}\", algorithm=MD5", true }; + + if (PlatformDetection.IsNetCore) + { + // These fail on full framework runs. + // TODO: #27113: Fix failing authentication test cases on different httpclienthandlers. + yield return new object[] { "Digest realm=\"testrealm1\", nonce=\"testnonce1\" Digest realm=\"testrealm2\", nonce=\"testnonce2\"", false }; + yield return new object[] { "Basic something, Digest something", false }; + yield return new object[] { $"Digest realm=\"testrealm\", nonce=\"testnonce\", algorithm=MD5 " + + $"Basic realm=\"testrealm\"", false }; + } + } + } +} diff --git a/src/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs b/src/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs index 5041ad04394a..63954d71380b 100644 --- a/src/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs +++ b/src/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs @@ -135,6 +135,29 @@ public sealed class SocketsHttpHandler_HttpClientHandler_MaxResponseHeadersLengt protected override bool UseSocketsHttpHandler => true; } + public sealed class SocketsHttpHandler_HttpClientHandler_Authentication_Test : HttpClientHandler_Authentication_Test + { + protected override bool UseSocketsHttpHandler => true; + + [Theory] + [MemberData(nameof(Authentication_SocketsHttpHandler_TestData))] + public async void SocketsHttpHandler_Authentication_Succeeds(string authenticateHeader, bool result) + { + await HttpClientHandler_Authentication_Succeeds(authenticateHeader, result); + } + + public static IEnumerable Authentication_SocketsHttpHandler_TestData() + { + // These test cases pass on SocketsHttpHandler, fail everywhere else. + // TODO: #27113: Fix failing authentication test cases on different httpclienthandlers. + yield return new object[] { "Basic realm=\"testrealm1\" basic realm=\"testrealm1\"", true }; + yield return new object[] { "Basic something digest something", true }; + yield return new object[] { "Digest ", false }; + yield return new object[] { "Digest realm=withoutquotes, nonce=withoutquotes", false }; + yield return new object[] { "Digest realm=\"testrealm\", nonce=\"testnonce\", algorithm=\"myown\"", false }; + } + } + public sealed class SocketsHttpHandler_HttpClientHandler_DuplexCommunication_Test : HttpClientTestBase { protected override bool UseSocketsHttpHandler => true; diff --git a/src/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj b/src/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj index 523c9d8bb30f..71f7c3885313 100644 --- a/src/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj +++ b/src/System.Net.Http/tests/FunctionalTests/System.Net.Http.Functional.Tests.csproj @@ -69,6 +69,9 @@ Common\System\Net\Http\LoopbackServer.cs + + Common\System\Net\Http\LoopbackServer.AuthenticationHelpers.cs + Common\System\Threading\Tasks\TaskTimeoutExtensions.cs @@ -79,6 +82,7 @@ + @@ -150,5 +154,8 @@ + + + diff --git a/src/System.Net.Http/tests/FunctionalTests/TestHelper.cs b/src/System.Net.Http/tests/FunctionalTests/TestHelper.cs index dc2f4859aa76..e60aa76711a2 100644 --- a/src/System.Net.Http/tests/FunctionalTests/TestHelper.cs +++ b/src/System.Net.Http/tests/FunctionalTests/TestHelper.cs @@ -16,6 +16,7 @@ namespace System.Net.Http.Functional.Tests { public static class TestHelper { + public static int PassingTestTimeoutMilliseconds => 60 * 1000; public static bool JsonMessageContainsKeyValue(string message, string key, string value) { // TODO (#5525): Align with the rest of tests w.r.t response parsing once the test server is finalized. @@ -88,6 +89,11 @@ public static Task WhenAllCompletedOrAnyFailed(params Task[] tasks) return TaskTimeoutExtensions.WhenAllOrAnyFailed(tasks); } + public static Task WhenAllCompletedOrAnyFailedWithTimeout(int timeoutInMilliseconds, params Task[] tasks) + { + return TaskTimeoutExtensions.WhenAllOrAnyFailed(tasks, timeoutInMilliseconds); + } + public static Func AllowAllCertificates = (_, __, ___, ____) => true; public static IPAddress GetIPv6LinkLocalAddress() => diff --git a/src/System.Net.Http/tests/UnitTests/DigestAuthenticationTests.cs b/src/System.Net.Http/tests/UnitTests/DigestAuthenticationTests.cs index 91dfecf9486e..38b689376041 100644 --- a/src/System.Net.Http/tests/UnitTests/DigestAuthenticationTests.cs +++ b/src/System.Net.Http/tests/UnitTests/DigestAuthenticationTests.cs @@ -12,6 +12,7 @@ public class DigestAuthenticationTests { private static readonly List s_keyListWithCountTwo = new List { "key1", "key2" }; private static readonly List s_valueListWithCountTwo = new List { "value1", "value2" }; + private static readonly List s_listWithCountOne = new List { "item1" }; private static readonly List s_emptyStringList = new List(); [Theory] @@ -30,8 +31,8 @@ public static IEnumerable DigestResponse_Challenge_TestData() yield return new object[] { "key1=value1,key2=value2", s_keyListWithCountTwo, s_valueListWithCountTwo }; yield return new object[] { "\tkey1===value1,key2 \t===\tvalue2", s_keyListWithCountTwo, s_valueListWithCountTwo }; yield return new object[] { " key1 = value1, key2 = value2,", s_keyListWithCountTwo, s_valueListWithCountTwo }; - yield return new object[] { "key1 === value1,key2=, value2", s_keyListWithCountTwo, new List { "value1", string.Empty } }; - yield return new object[] { "key1,==value1,,, key2=\"value2\", key3 m", new List { "key1,", "key2" }, s_valueListWithCountTwo }; + yield return new object[] { "item1 === item1,key2=, value2", s_listWithCountOne, s_listWithCountOne }; + yield return new object[] { "item1,==item1,,, key2=\"value2\", key3 m", new List { "item1," }, s_listWithCountOne }; yield return new object[] { "key1= \"value1 \",key2 = \"v alu#e2\" ,", s_keyListWithCountTwo, new List { "value1 ", "v alu#e2"} }; yield return new object[] { "key1 ", s_emptyStringList, s_emptyStringList }; yield return new object[] { "=====", s_emptyStringList, s_emptyStringList };