ASP.NET Core user application access token management

This article looks at management application access tokens in an ASP.NET Core web application. Any application with or without a user can use application access tokens as long as the application can persist the tokens in a safe way.

Code: https://github.com/damienbod/token-mgmt-ui-application

Blogs in this series

Setup

The ASP.NET Core web application authenticates using OpenID Connect and OpenIddict as the secure token server. The application needs to use data from an app-to-app resource. An OAuth client credential flow is used to get an application access token to access the API. The OAuth client credentials flow can only be used when it can keep a secret. This token has nothing in common with the delegated access token from the user authentication. The application is persisted once for the application. An in-memory cache is used for this. The application sends the application access token as a bearer token to the API.

What must an application manage?

An access token management solution must ensure that tokens are securely stored per application for application tokens and updated after each UI authentication or refresh. The solution should be robust to handle token expiration, function seamlessly after restarts, and support multi-instance deployments. The tokens must be persisted safely in multiple instance setups. Additionally, it must effectively manage scenarios involving invalid or missing access tokens.

Properties of token management in the solution setup:

  • The access token is persisted per application
  • The token expires
  • The token needs to be persisted somewhere safely (Safe and encrypted storage if not in-memory)
  • The solution must work after restarts
  • The solution must work for multiple instances when deployed to multi-instance deployments.
  • The solution must handle invalid access tokens or missing access tokens

Implementation example

An ApplicationAccessTokenCache service is used to manage the access tokens for the application. The service is registered as a singleton and runs once for the whole application. Each request scope can use this. The application looks in the cache for a valid token and if no valid token is present, the service requests a new access token using the OAuth client credentials flow. The token is persisted to the cache using the client ID. This means only one token can exist per client definition.

using IdentityModel.Client;
using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json;

namespace Ui;

/// <summary>
/// Cache persists token per application
/// </summary>
public class ApplicationAccessTokenCache
{
    private readonly ILogger<ApplicationAccessTokenCache> _logger;
    private readonly HttpClient _httpClient;
    private readonly IConfiguration _configuration;

    private static readonly object _lock = new();
    private readonly IDistributedCache _cache;

    private const int cacheExpirationInDays = 1;

    private class AccessTokenItem
    {
        public string AccessToken { get; set; } = string.Empty;
        public DateTime ExpiresIn { get; set; }
    }

    public ApplicationAccessTokenCache(
        IConfiguration configuration,
        IHttpClientFactory httpClientFactory,
        ILoggerFactory loggerFactory,
        IDistributedCache cache)
    {
        _configuration = configuration;
        _httpClient = httpClientFactory.CreateClient();
        _logger = loggerFactory.CreateLogger<ApplicationAccessTokenCache>();
        _cache = cache;
    }

    public async Task<string> GetApiToken(string clientId, string scope, string secret)
    {
        var accessToken = GetFromCache(clientId);

        if ((accessToken != null) && (accessToken.ExpiresIn > DateTime.UtcNow))
        {
            return accessToken.AccessToken;
        }

        _logger.LogDebug("GetApiToken new from secure token server for {clientId}", clientId);

        var newAccessToken = await GetInternalApiToken(clientId, scope, secret);
        AddToCache(clientId, newAccessToken);

        return newAccessToken.AccessToken;
    }

    private async Task<AccessTokenItem> GetInternalApiToken(string clientId, string scope, string secret)
    {
        try
        {
            var disco = await HttpClientDiscoveryExtensions.GetDiscoveryDocumentAsync(
                _httpClient,
                _configuration["OpenIDConnectSettings:Authority"]);

            if (disco.IsError)
            {
                _logger.LogError("disco error Status code: {discoIsError}, Error: {discoError}", disco.IsError, disco.Error);
                throw new ApplicationException($"Status code: {disco.IsError}, Error: {disco.Error}");
            }

            var tokenResponse = await HttpClientTokenRequestExtensions.RequestClientCredentialsTokenAsync(_httpClient, 
                new ClientCredentialsTokenRequest
                {
                    Scope = scope,
                    ClientSecret = secret,
                    Address = disco.TokenEndpoint,
                    ClientId = clientId
                });

            if (tokenResponse.IsError)
            {
                _logger.LogError("tokenResponse.IsError Status code: {tokenResponseIsError}, Error: {tokenResponseError}", tokenResponse.IsError, tokenResponse.Error);
                throw new ApplicationException($"Status code: {tokenResponse.IsError}, Error: {tokenResponse.Error}");
            }

            return new AccessTokenItem
            {
                ExpiresIn = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn),
                AccessToken = tokenResponse.AccessToken!
            };

        }
        catch (Exception e)
        {
            _logger.LogError("Exception {e}", e);
            throw new ApplicationException($"Exception {e}");
        }
    }

    private void AddToCache(string key, AccessTokenItem accessTokenItem)
    {
        var options = new DistributedCacheEntryOptions()
            .SetSlidingExpiration(TimeSpan.FromDays(cacheExpirationInDays));

        lock (_lock)
        {
            _cache.SetString(key, JsonSerializer.Serialize(accessTokenItem), options);
        }
    }

    private AccessTokenItem? GetFromCache(string key)
    {
        var item = _cache.GetString(key);
        if (item != null)
        {
            return JsonSerializer.Deserialize<AccessTokenItem>(item);
        }

        return null;
    }
}

The ApplicationUsersService class uses the access token from the token service. This is a scoped service and the data is requested from the API using a bearer token in the authorization header.

using IdentityModel.Client;

namespace Ui;

public class ApplicationUsersService
{
    private readonly IConfiguration _configuration;
    private readonly IHttpClientFactory _clientFactory;
    private readonly ApplicationAccessTokenCache _apiTokenCacheClient;

    public ApplicationUsersService(IConfiguration configuration,
        IHttpClientFactory clientFactory,
        ApplicationAccessTokenCache apiTokenCacheClient)
    {
        _configuration = configuration;
        _clientFactory = clientFactory;
        _apiTokenCacheClient = apiTokenCacheClient;
    }

    /// <summary>
    /// HttpContext is used to get the access token and it is passed as a parameter
    /// </summary>
    public async Task<string> GetPhotoAsync()
    {
        try
        {
            var client = _clientFactory.CreateClient();

            client.BaseAddress = new Uri(_configuration["AuthConfigurations:ProtectedApiUrl"]!);

            var access_token = await _apiTokenCacheClient.GetApiToken(
                "CC",
                "myccscope",
                "cc_secret"
            );

            client.SetBearerToken(access_token);

            var response = await client.GetAsync("api/ApplicationUsers/photo");
            if (response.IsSuccessStatusCode)
            {
                var data = await response.Content.ReadAsStringAsync();

                if (data != null)
                    return data;

                return string.Empty;
            }

            throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
        }
        catch (Exception e)
        {

            throw new ApplicationException($"Exception {e}");
        }
    }
}

The required services are added the the application in the program file.

builder.Services.AddSingleton<ApplicationAccessTokenCache>();
builder.Services.AddScoped<ApplicationUsersService>();
builder.Services.AddHttpClient();

builder.Services.AddDistributedMemoryCache();

The token cache works great when using in-memory cache. If using a persistent cache, care needs to be taken that the access tokens are persisted in a safe way.

Notes

In follow up blogs, I will look at the different ways and the different types of strategies which are used to implement token management in ASP.NET Core web applications.

  • Microsoft.Identity.Web delegated access tokens & OBO access tokens
  • Microsoft.Identity.Client application tokens
  • Azure SDK tokens
  • Handling multiple access tokens
  • OAuth Token Exchange for downstream user delegated access tokens

Links

https://learn.microsoft.com/en-us/aspnet/core/security/authentication/social/additional-claims

https://github.com/dotnet/aspnetcore/issues/8175

3 comments

  1. Unknown's avatar

    […] ASP.NET Core user application access token management […]

  2. […] ASP.NET Core user application access token management (Damien Bowden) […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.