Description
Description
I have a client app that talks to remote HTTP servers via HttpClient
, and authentication handled via HttpClientHandler.Credentials
, also using HttpClientHandler.PreAuthenticate = true
to avoid needlessly hammering the web server with unauthenticated HTTP requests that need to get HTTP 401 responses (fewer requests also reduces latency, from the client point of view).
Say the customer logs into the website and changes their password, or we're sending OAuth2 tokens over Basic auth, rather than Bearer. It appears that HttpClientHandler
won't refresh the credential in its credential cache, despite getting a fresh credential from HttpClientHandler.Credentials
.
FWIW, it also looks like there's a potential perf improvement by avoiding getting the credential twice. In the sample output below, look for the "Credential Plugin: returning credential" lines.
Reproduction Steps
Here's a sample app that demonstrates:
using System.Net;
using System.Text;
HttpListener listener = new HttpListener();
string prefix = "http://localhost:1234/";
listener.Prefixes.Add(prefix);
listener.Start();
var credentialPlugin = new CredentialPlugin();
Task httpHandlerTask = HandleHttpRequests(listener);
using HttpClientHandler httpClientHandler = new()
{
PreAuthenticate = true,
Credentials = credentialPlugin
};
using HttpClient httpClient = new(httpClientHandler);
await SendRequest(httpClient, prefix);
await SendRequest(httpClient, prefix + "api1/ABC");
await SendRequest(httpClient, prefix + "api2/123");
credentialPlugin.RefreshToken();
await SendRequest(httpClient, prefix + "api1/DEF");
// Try another time, just in case the cred cache is updated after the first failed attempt
await SendRequest(httpClient, prefix + "api1/DEF");
await SendRequest(httpClient, prefix + "api2/456");
listener.Stop();
await httpHandlerTask;
async Task SendRequest(HttpClient client, string url)
{
using var response = await httpClient.GetAsync(url);
Console.WriteLine($"Client: response code {response.StatusCode} from {url}");
}
async Task HandleHttpRequests(HttpListener listener)
{
while (true)
{
try
{
var request = await listener.GetContextAsync();
var authenticationHeader = request.Request.Headers.Get("Authorization");
if (string.IsNullOrEmpty(authenticationHeader))
{
Console.WriteLine("Server: unauthenticated request to " + request.Request.RawUrl);
request.Response.AddHeader("WWW-Authenticate", "basic");
request.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
else
{
var auth = request.Request.Headers["Authorization"];
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(auth.Replace("Basic", "", StringComparison.OrdinalIgnoreCase)));
if (decoded == $"{credentialPlugin.UserName}:{credentialPlugin.Password}")
{
Console.WriteLine($"Server: authenticated request ({decoded}) to {request.Request.RawUrl}");
request.Response.StatusCode = (int)HttpStatusCode.OK;
}
else
{
Console.WriteLine($"Server: wrong username/password ({decoded}) request to " + request.Request.RawUrl);
request.Response.AddHeader("WWW-Authenticate", "basic");
request.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
}
}
request.Response.Close();
}
catch
{
// this is how http listener "gracefully" stops?
return;
}
}
}
class CredentialPlugin : ICredentials
{
public CredentialPlugin()
{
UserName = "username";
counter = 0;
Password = "password";
}
private int counter;
public string UserName {get; private set;}
public string Password { get;private set;}
// pretend this comes from an OAuth2 service
public void RefreshToken()
{
counter++;
Password = "password" + counter;
Console.WriteLine($"Credential Plugin: Changed to '{UserName}:{Password}'");
}
NetworkCredential? ICredentials.GetCredential(Uri uri, string authType)
{
Console.WriteLine($"Credential Plugin: returning credential '{UserName}:{Password}'");
return new NetworkCredential(UserName, Password);
}
}
Expected behavior
After the "credential plugin" changes the password, I would expect HttpClient to:
- Send a request to the server with the old credentials (which it does)
- When the request fails with 401, get new credentials (which it does)
- Send a new request to the server with the new credentials (it does not currently)
- Update the credential cache, so subsequent requests use the new credentials, not the old credentials (it does not currently)
Basically, when it gets an HTTP 401 response, I'd like it to clear the credentials from the pre-auth cache, and then mimic the behaviour when the first request didn't have auth in the first place.
Here's pseudocode (that looks awfully like valid C# code) that explains my expected behaviour. Basically, try once (preauth if possible), and if the response is a 401, try a second time if credentials can be obtained.
async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request)
{
HttpResponseMessage response;
if (_options.PreAuthenticate && TryGetCachedCredential(request.Uri, out var credential)
{
response = await SendWithAuthAsync(request, credential);
}
else
{
response = await SendVerbatimAsync(request);
}
if (response.Status == 401 && _options.Credentials != null)
{
credential = _options.Credentials.GetCredentials(request.Uri, response.Headers.WwwAuthorization.Scheme);
if (credential != null)
{
response = await SendWithAuthAsync(request, credential);
if (response.Status != 401)
{
UpdatePreauthCache(request.Uri, credential);
}
}
}
return response;
}
I'm sure there's a lot more complexity than what I assume, but I hope it explains what I thought HttpClient was going to do, and what I'd like it to do.
Actual behavior
Here's the output of my sample app. You can see it only tries the first password over and over, and despite asking the (simulated) "credential plugin" for a fresh token (twice per HTTP request), it keeps trying the old
Server: unauthenticated request to /
Credential Plugin: returning credential 'username:password'
Credential Plugin: returning credential 'username:password'
Server: authenticated request (username:password) to /
Client: response code OK from http://localhost:1234/
Server: authenticated request (username:password) to /api1/ABC
Client: response code OK from http://localhost:1234/api1/ABC
Server: authenticated request (username:password) to /api2/123
Client: response code OK from http://localhost:1234/api2/123
Credential Plugin: Changed to 'username:password1'
Server: wrong username/password (username:password) request to /api1/DEF
Credential Plugin: returning credential 'username:password1'
Credential Plugin: returning credential 'username:password1'
Client: response code Unauthorized from http://localhost:1234/api1/DEF
Server: wrong username/password (username:password) request to /api1/DEF
Credential Plugin: returning credential 'username:password1'
Credential Plugin: returning credential 'username:password1'
Client: response code Unauthorized from http://localhost:1234/api1/DEF
Server: wrong username/password (username:password) request to /api2/456
Credential Plugin: returning credential 'username:password1'
Credential Plugin: returning credential 'username:password1'
Client: response code Unauthorized from http://localhost:1234/api2/456
Regression?
No. Same behaviour on .NET Framework, and with WinHttpHandler.
Known Workarounds
- Don't use PreAuthenticate
The problem with this is that it doubles the number of HTTP requests the client sends to the server (and increases server load) because time the app asks HttpClient to send a request, it's first sent unauthenticated, and then HttpClient will silently try again with credentials. Depending on the customer's network, the latency in doubling the number of requests can also have a noticeable perf impact, depending on the scenario.
- Don't use HttpClientHandler.Credentials for HTTP Basic auth
In an app that supports NTLM, Kerberos/Negotiate, and Basic auth, the app has to increase complexity, duplicating some code that HttpClient already has to handle auth depending on the scheme requested by the 401 response's WWW-Authenticate header.
Configuration
No response
Other information
No response