Photo by Ashwini Chaudhary

Introduction

When working with http APIs you'll sooner or later run into access tokens. I will not go into any depth of the whys and the hows but let's do a quick breakdown.

  • Used to authenticate a user/client.
  • Usually sent as a header.
  • Usually retrived by exchanging a client id & client secret.
  • Usually expires after a short period of time and needs to be refreshed.

Let's have a look at how we can work with access tokens in a smart way using dotnet.

Implementations

First version

This is our current implementation. It has worked GREAT for many many years. The API we are talking to is not using any authentication whatsoever.

public class CompanyHttpClient
{
    private readonly HttpClient _httpClient;

    public CompanyHttpClient(HttpClient httpClient)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }

    public async Task<Company> Get(string companyName)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, $"/api/companies/{companyName}");

        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        response.EnsureSuccessStatusCode();
        await using var responseContentStream = await response.Content.ReadAsStreamAsync();
        
        var company = await JsonSerializer.DeserializeAsync<Company>(
            responseContentStream,
            DefaultJsonSerializerOptions.Options);

        return company ?? throw new Exception("Failed to deserialize company");
    }
}

Second version

All of a sudden, the API starts to return 401 Unauthorized. We also recieve the following email:

Hello!

We've added authentication to the Company API, you'll need to use the following access token to access it:

Access Token: super-secret-access-token-that-lives-forever

Fair enough I guess, let's add it.

public class CompanyHttpClient2
{
    private const string AccessToken = "super-secret-access-token-that-lives-forever";
    private readonly HttpClient _httpClient;

    public CompanyHttpClient2(HttpClient httpClient)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }

    public async Task<Company> Get(string companyName)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, $"/api/companies/{companyName}");
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", AccessToken);

        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        response.EnsureSuccessStatusCode();
        await using var responseContentStream = await response.Content.ReadAsStreamAsync();

        var company = await JsonSerializer.DeserializeAsync<Company>(
            responseContentStream,
            DefaultJsonSerializerOptions.Options);

        return company ?? throw new Exception("Failed to deserialize company");
    }
}

I mean, besides from having the secret hardcoded in plaintext, this was a quick and simple fix, ship it!

Time goes on, everything works perfect...until it doesn't :). We receive a new email:

Hello!

We take security very seriously here at Company Company.

We've been told that using long lived access tokens is considered bad practice; you'll now need to obtain a new token every hour by using this client id and client secret.

Client id: jeho-consulting

Client secret: check your phone

Now, this is great and all, but here's where things starts to get a bit tricky. I've seen all kinds of different approaches of how to handle this, both good and bad.

Let's look at a couple of different implementations.

Third version

Let's create a method on our CompanyHttpClient3 that's responsible for retrieving the access token.

public async Task<AccessToken> GetAccessToken()
{
    var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token/")
    {
        Content = new FormUrlEncodedContent(new KeyValuePair<string?, string?>[]
        {
            new("client_id", _clientId),
            new("client_secret", _clientSecret),
            new("scope", "company-api"),
            new("grant_type", "client_credentials")
        })
    };

    using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
    response.EnsureSuccessStatusCode();
    await using var responseContentStream = await response.Content.ReadAsStreamAsync();

    var accessToken = await JsonSerializer.DeserializeAsync<AccessToken>(
        responseContentStream,
        DefaultJsonSerializerOptions.Options);

    return accessToken ?? throw new Exception("Failed to deserialize access token");
}

AccessToken

public class AccessToken
{
    // I usually let my token "expire" 5 minutes before it's actual expiration
    // to avoid using expired tokens and getting 401.
    private static readonly TimeSpan Threshold = new(0, 5, 0);

    public AccessToken(string token, int expiresInSeconds) : this(token, null, expiresInSeconds)
    {

    }

    public AccessToken(
        string token,
        string? refreshToken,
        int expiresInSeconds)
    {
        Token = token;
        RefreshToken = refreshToken;
        ExpiresInSeconds = expiresInSeconds;
        Expires = DateTime.UtcNow.AddSeconds(ExpiresInSeconds);
    }

    public string Token { get; }
    public string? RefreshToken { get; }
    public int ExpiresInSeconds { get; }
    public DateTime Expires { get; }
    public bool Expired => (Expires - DateTime.UtcNow).TotalSeconds <= Threshold.TotalSeconds;
}

Our CompanyHttpClient3 now looks like this

public class CompanyHttpClient3
{
    private readonly string _clientId;
    private readonly string _clientSecret;
    private readonly HttpClient _httpClient;

    public CompanyHttpClient3(HttpClient httpClient, IConfiguration configuration)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        var config = configuration ?? throw new ArgumentNullException(nameof(configuration));
        _clientId = config.GetValue<string>("CompanyApi:ClientId");
        _clientSecret = config.GetValue<string>("CompanyApi:ClientSecret");
    }

    public async Task<AccessToken> GetAccessToken()
    {
        var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token/")
        {
            Content = new FormUrlEncodedContent(new KeyValuePair<string?, string?>[]
            {
                new("client_id", _clientId),
                new("client_secret", _clientSecret),
                new("scope", "company-api"),
                new("grant_type", "client_credentials")
            })
        };

        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        response.EnsureSuccessStatusCode();
        await using var responseContentStream = await response.Content.ReadAsStreamAsync();

        var accessToken = await JsonSerializer.DeserializeAsync<AccessToken>(
            responseContentStream,
            DefaultJsonSerializerOptions.Options);

        return accessToken ?? throw new Exception("Failed to deserialize access token");
    }

    public async Task<Company> Get(string companyName)
    {
        var accessToken = await GetAccessToken();
        var request = new HttpRequestMessage(HttpMethod.Get, $"/api/companies/{companyName}");
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token);

        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        response.EnsureSuccessStatusCode();
        await using var responseContentStream = await response.Content.ReadAsStreamAsync();

        var company = await JsonSerializer.DeserializeAsync<Company>(
            responseContentStream,
            DefaultJsonSerializerOptions.Options);

        return company ?? throw new Exception("Failed to deserialize company");
    }
}

There's one problem with this approach. We are fetching a new access token everytime we are calling the Get method.
httpclient-3-everytime

Since the access token we get back is valid for 1 hour it would be a good idea to cache it.

Fourth version

Let's refactor a bit so that we can cache our access token. We've now added a private static field that will store the access token. We've also changed the GetAccessToken a bit, we moved the fetching logic to a separate method. If we have a token (and it's not expired), we return it, if not, we fetch a new one.

private static AccessToken? _accessToken;
....
....

public async Task<AccessToken> GetAccessToken()
{
    if (_accessToken is {Expired: false})
    {
        return _accessToken;
    }

    _accessToken = await FetchToken();
    return _accessToken;
}

private async Task<AccessToken> FetchToken()
{
    var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token/")
    {
        Content = new FormUrlEncodedContent(new KeyValuePair<string?, string?>[]
        {
            new("client_id", _clientId),
            new("client_secret", _clientSecret),
            new("scope", "company-api"),
            new("grant_type", "client_credentials")
        })
    };

    using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
    response.EnsureSuccessStatusCode();
    await using var responseContentStream = await response.Content.ReadAsStreamAsync();

    var accessToken = await JsonSerializer.DeserializeAsync<AccessToken>(
        responseContentStream,
        DefaultJsonSerializerOptions.Options);

    return accessToken ?? throw new Exception("Failed to deserialize access token");
}

The full version of our CompanyHttpClient4 looks like this.

public class CompanyHttpClient4
{
    private static AccessToken? _accessToken;
    private readonly string _clientId;
    private readonly string _clientSecret;
    private readonly HttpClient _httpClient;

    static CompanyHttpClient4()
    {
        _accessToken = null!;
    }

    public CompanyHttpClient4(HttpClient httpClient, IConfiguration configuration)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
        var config = configuration ?? throw new ArgumentNullException(nameof(configuration));
        _clientId = config.GetValue<string>("CompanyApi:ClientId");
        _clientSecret = config.GetValue<string>("CompanyApi:ClientSecret");
    }

    public async Task<AccessToken> GetAccessToken()
    {
        if (_accessToken is {Expired: false})
        {
            return _accessToken;
        }

        _accessToken = await FetchToken();
        return _accessToken;
    }

    public async Task<Company> Get(string companyName)
    {
        var accessToken = await GetAccessToken();
        var request = new HttpRequestMessage(HttpMethod.Get, $"/api/companies/{companyName}");
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken.Token);

        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        response.EnsureSuccessStatusCode();
        await using var responseContentStream = await response.Content.ReadAsStreamAsync();

        var company = await JsonSerializer.DeserializeAsync<Company>(
            responseContentStream,
            DefaultJsonSerializerOptions.Options);

        return company ?? throw new Exception("Failed to deserialize company");
    }

    private async Task<AccessToken> FetchToken()
    {
        var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token/")
        {
            Content = new FormUrlEncodedContent(new KeyValuePair<string?, string?>[]
            {
                new("client_id", _clientId),
                new("client_secret", _clientSecret),
                new("scope", "company-api"),
                new("grant_type", "client_credentials")
            })
        };

        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        response.EnsureSuccessStatusCode();
        await using var responseContentStream = await response.Content.ReadAsStreamAsync();

        var accessToken = await JsonSerializer.DeserializeAsync<AccessToken>(
            responseContentStream,
            DefaultJsonSerializerOptions.Options);

        return accessToken ?? throw new Exception("Failed to deserialize access token");
    }
}

Great! We are now caching our token, are we done now?
No. Can you spot the problem?
httpclient-4

Yes. A race condition. When calling this code in parallel we run into a race condition. Until the first FetchToken call is finished, we will call the FetchToken method for every call to the Get method. Not good. Let's fix it.

Fifth version

private static readonly SemaphoreSlim AccessTokenSemaphore;
...
...
static CompanyHttpClient5()
{
    _accessToken = null!;
    AccessTokenSemaphore = new SemaphoreSlim(1, 1);
}

...
...

private async Task<AccessToken> FetchToken()
{
    try
    {
        await AccessTokenSemaphore.WaitAsync();

        if (_accessToken is { Expired: false })
        {
            return _accessToken;
        }
        
        var request = new HttpRequestMessage(HttpMethod.Post, "/connect/token/")
        {
            Content = new FormUrlEncodedContent(new KeyValuePair<string?, string?>[]
            {
                new("client_id", _clientId),
                new("client_secret", _clientSecret),
                new("scope", "company-api"),
                new("grant_type", "client_credentials")
            })
        };

        using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        response.EnsureSuccessStatusCode();
        await using var responseContentStream = await response.Content.ReadAsStreamAsync();

        var accessToken = await JsonSerializer.DeserializeAsync<AccessToken>(
            responseContentStream,
            DefaultJsonSerializerOptions.Options);

        return accessToken ?? throw new Exception("Failed to deserialize access token");
    }
    finally
    {
        AccessTokenSemaphore.Release(1);
    }
}

We've now added a SemaphoreSlim to ensure that only one call at a time will be made to the FetchToken endpoint.
When calling the Get method in parallel it will now look like this.
httpclient-5

One important thing to note here is that if the call to the FetchToken endpoint takes time, your app will just...wait. It's expected of course, but remember to have a resonable time out set. If the call time out, you'll most likely want to be notified somehow and take action.

Bonus approach

You could refactor the handling of the token to it's own class, something like AccessTokenStorage. That class could have two methods, GetToken and RefreshToken. You could then create a BackgroundService that runs every minute and checks if the token is about to expire. If it's close to expiration, a new token will be fetched and replace the old one. Your CompanyHttpClient would then use the GetToken method in AccessTokenStorage to get the token.

Conclusion

I usually use the fifth version myself, but you are free, of course, to use whatever approach you want. The important thing is that you at least have some kind of strategy when it comes to renewing your tokens. Some people tend to listen to 401s from the API before refreshing their tokens. I don't like that approach, since you know when the token will expire, I don't see why you would want to wait for a 401 and then handle the refreshing of the token. I think that it just makes the actual business code more noisy, but to each their own... :)

All code in this post can be found on GitHub