Effortless Token Refresh with Refit in C#: A Step-by-Step Guide
Introduction
When working with REST APIs in C#, managing JWT authentication tokens can become a challenge, especially when the access token expires during concurrent API calls. This article demonstrates how to implement a seamless token refresh mechanism using Refit, a lightweight HTTP client library for .NET. We’ll handle token expiration elegantly, even in concurrent scenarios, and ensure retry logic is robust.
Why Use Refit?
Refit simplifies API consumption by allowing you to define interfaces for your endpoints. It abstracts away the complexity of HTTP client setup and lets you focus on business logic. Combining Refit with an effective token refresh strategy ensures a clean and maintainable codebase.
Problem: Token Expiration and Concurrent API Calls
When multiple API calls are made simultaneously using an expired access token, refreshing the token once and ensuring all calls are retried is crucial. Without proper concurrency management, redundant refresh calls may occur, leading to inconsistent behavior.
Solution: Token Refresh with Concurrency Handling
We’ll use a custom AuthHeaderHandler
to handle token expiration and refresh logic. The handler will integrate with an IAuthService
interface generated using Refit, making the solution modular and testable.
Step 1: Define IAuthService
with Refit
public interface IAuthService
{
[Post("/auth/refresh")]
Task<TokenResponse> RefreshAccessTokenAsync([Body(BodySerializationMethod.UrlEncoded)] Dictionary<string, string> request);
}
This interface defines the API endpoint for refreshing the token.
Step 2: Implement the AuthHeaderHandler
public sealed class AuthHeaderHandler : DelegatingHandler
{
private readonly IAuthService _authService;
private static readonly SemaphoreSlim _refreshLock = new(1, 1);
private static bool _isTokenRefreshing = false;
public AuthHeaderHandler(IAuthService authService)
{
_authService = authService ?? throw new ArgumentNullException(nameof(authService));
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(TokenStore.AccessToken))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TokenStore.AccessToken);
}
var response = await base.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
await _refreshLock.WaitAsync(cancellationToken);
try
{
if (!_isTokenRefreshing)
{
_isTokenRefreshing = true;
var refreshRequest = new Dictionary<string, string>
{
{ "refresh_token", TokenStore.RefreshToken }
};
var tokenResponse = await _authService.RefreshAccessTokenAsync(refreshRequest);
TokenStore.AccessToken = tokenResponse.AccessToken;
TokenStore.RefreshToken = tokenResponse.RefreshToken;
}
}
finally
{
_isTokenRefreshing = false;
_refreshLock.Release();
}
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", TokenStore.AccessToken);
response = await base.SendAsync(request, cancellationToken);
}
return response;
}
}
The handler:
- Attaches the access token to requests.
- Refreshes the token if the server responds with
401 Unauthorized
. - Ensures only one refresh operation occurs at a time using
SemaphoreSlim
.
Step 3: Configure ConsumerBuilder
internal static class ConsumerBuilder
{
internal static T Create<T>() where T : class
{
// Create a dedicated HttpClient for IAuthService
var authHttpClient = new HttpClient
{
BaseAddress = new Uri(ConsumerConfiguration.Instance.EntryPoint),
Timeout = TimeSpan.FromMilliseconds(ConsumerConfiguration.Instance.MiliSecondTimeOut)
};
var authService = RestService.For<IAuthService>(authHttpClient);
var httpClient = new HttpClient(new AuthHeaderHandler(authService)
{
InnerHandler = new HttpClientHandler()
})
{
BaseAddress = new Uri(ConsumerConfiguration.Instance.EntryPoint),
Timeout = TimeSpan.FromMilliseconds(ConsumerConfiguration.Instance.MiliSecondTimeOut)
};
var settings = new RefitSettings
{
ContentSerializer = new NewtonsoftJsonContentSerializer(
new JsonSerializerSettings
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
})
};
return RestService.For<T>(httpClient, settings);
}
}
Step 4: Token Storage and Model
Token Store
public static class TokenStore
{
public static string AccessToken { get; set; }
public static string RefreshToken { get; set; }
}
Token Response Model
public class TokenResponse
{
[JsonProperty("access_token")]
public string AccessToken { get; set; }
[JsonProperty("refresh_token")]
public string RefreshToken { get; set; }
}
Step 5: How It Works
- Attach Token: The
AuthHeaderHandler
attaches the access token to each request. - Refresh Token: When a
401 Unauthorized
response is received, the handler triggers the token refresh logic. - Concurrency Management: Using
SemaphoreSlim
, only one thread refreshes the token while others wait. - Retry Logic: After refreshing the token, the handler retries the original request.
Benefits of This Approach
- Clean Architecture: Encapsulates authentication logic in
AuthHeaderHandler
andIAuthService
. - Concurrency Safe: Manages token refresh efficiently in concurrent scenarios.
- Scalable: Works seamlessly for multiple Refit clients.
- Testable: Both the handler and the
IAuthService
can be mocked for unit testing.
Conclusion
With this approach, you can handle token expiration in a clean and efficient manner while leveraging the power of Refit. Whether you’re building a small-scale project or a large enterprise solution, this pattern ensures robustness and scalability.
Have thoughts or questions? Let’s discuss in the comments below!