This article shows how to persist access tokens for a trusted ASP.NET Core application which needs to access secure APIs. These tokens which are persisted are not meant for public clients, but are used for the service to service communication.
Code: https://github.com/damienbod/AspNetCoreHybridFlowWithApi
Posts in this series:
- Securing an ASP.NET Core MVC application which uses a secure API
- Handling Access Tokens for private APIs in ASP.NET Core
Setup
The software system consists of 3 applications, a web client with a UI and user, an API which is used by the web client and a secure token service, implemented using IdentityServer4.
The tokens persisted in this example are used for the communication between the web application and the trusted API in the service. The application gets the access tokens for the service to service communication. The tokens for the identities (users + application) are not used here. In the previous post, each time the user requested a view, the API service requested the disco service data (OpenID Connect well known endpoints). Then it requested the access token from the secure token service token endpoint. After it requested the API resource. We want to re-use the access tokens instead of always doing the extra 2 HTTP requests for the web UI requests.
The ApiService is used to access the API for the identity. This is a scoped or transient instance in the IoC and for each identity different.
The service uses the API token client service which is a singleton. The service is used to get the access tokens and persist them as long as the tokens are valid. The service then uses the access token to get the data from the API resource.
using Microsoft.Extensions.Options; using Newtonsoft.Json.Linq; using System; using System.Net.Http; using System.Threading.Tasks; namespace WebHybridClient { public class ApiService { private readonly IOptions<AuthConfigurations> _authConfigurations; private readonly IHttpClientFactory _clientFactory; private readonly ApiTokenCacheClient _apiTokenClient; public ApiService( IOptions<AuthConfigurations> authConfigurations, IHttpClientFactory clientFactory, ApiTokenCacheClient apiTokenClient) { _authConfigurations = authConfigurations; _clientFactory = clientFactory; _apiTokenClient = apiTokenClient; } public async Task<JArray> GetApiDataAsync() { try { var client = _clientFactory.CreateClient(); client.BaseAddress = new Uri(_authConfigurations.Value.ProtectedApiUrl); var access_token = await _apiTokenClient.GetApiToken( "ProtectedApi", "scope_used_for_api_in_protected_zone", "api_in_protected_zone_secret" ); client.SetBearerToken(access_token); var response = await client.GetAsync("api/values"); if (response.IsSuccessStatusCode) { var responseContent = await response.Content.ReadAsStringAsync(); var data = JArray.Parse(responseContent); return data; } throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}"); } catch (Exception e) { throw new ApplicationException($"Exception {e}"); } } } }
The API token client service use the GetApiToken method to get the access token. It requires an API name, a scope and a secret to get the token.
var access_token = await _apiTokenClient.GetApiToken( "ProtectedApi", "scope_used_for_api_in_protected_zone", "api_in_protected_zone_secret" );
The first time the ASP.NET Core instance requests an access token, it gets the well known endpoint data from the Auth server, and then gets the access token for the parameters provided. The token response is saved to a concurrent dictionary, so that it can be reused.
private async Task<AccessTokenItem> getApiToken(string api_name, string api_scope, string secret) { try { var disco = await HttpClientDiscoveryExtensions.GetDiscoveryDocumentAsync( _httpClient, _authConfigurations.Value.StsServer); if (disco.IsError) { _logger.LogError($"disco error Status code: {disco.IsError}, Error: {disco.Error}"); throw new ApplicationException($"Status code: {disco.IsError}, Error: {disco.Error}"); } var tokenResponse = await HttpClientTokenRequestExtensions.RequestClientCredentialsTokenAsync(_httpClient, new ClientCredentialsTokenRequest { Scope = api_scope, ClientSecret = secret, Address = disco.TokenEndpoint, ClientId = api_name }); if (tokenResponse.IsError) { _logger.LogError($"tokenResponse.IsError Status code: {tokenResponse.IsError}, Error: {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}"); throw new ApplicationException($"Exception {e}"); } }
The GetApiToken is the public method for this service. This method checks if a valid access token exists for this API, and returns it from memory if it does. Otherwise, it gets a new token from the secure token service with the extra 2 HTTP calls.
public async Task<string> GetApiToken(string api_name, string api_scope, string secret) { if (_accessTokens.ContainsKey(api_name)) { var accessToken = _accessTokens.GetValueOrDefault(api_name); if (accessToken.ExpiresIn > DateTime.UtcNow) { return accessToken.AccessToken; } else { // remove _accessTokens.TryRemove(api_name, out AccessTokenItem accessTokenItem); } } _logger.LogDebug($"GetApiToken new from STS for {api_name}"); // add var newAccessToken = await getApiToken( api_name, api_scope, secret); _accessTokens.TryAdd(api_name, newAccessToken); return newAccessToken.AccessToken; }
What’s wrong with this?
The above service works well, but what if the ASP.NET Core application is deployed as a multi-instance? Each instance of the application would have it’s own in memory access tokens, which are updated each time the tokens expire. What if I want to share tokens between instances or even services? Then the software system would be making extra requests which could be optimized.
Using Cache to solve and improve performance with multiple instances
A distributed cache could be used to solve this problem. For example a Redis cache could be used to persist the access tokens for the services, and used in all trusted services which request secure API data. These are not the tokens used for the identities, but the tokens for the service to service communication. This should be in a protected zone, and if you save access tokens to a shared cache, then care has to be taken, that this cannot be abused!
The service works just like the service above except a cache is used instead of a concurrent dictionary.
using IdentityModel.Client; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using System; using System.Net.Http; using System.Threading.Tasks; namespace WebHybridClient { public class ApiTokenCacheClient { private readonly ILogger<ApiTokenCacheClient> _logger; private readonly HttpClient _httpClient; private readonly IOptions<AuthConfigurations> _authConfigurations; private static readonly Object _lock = new Object(); private IDistributedCache _cache; private const int cacheExpirationInDays = 1; private class AccessTokenItem { public string AccessToken { get; set; } = string.Empty; public DateTime ExpiresIn { get; set; } } public ApiTokenCacheClient( IOptions<AuthConfigurations> authConfigurations, IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory, IDistributedCache cache) { _authConfigurations = authConfigurations; _httpClient = httpClientFactory.CreateClient(); _logger = loggerFactory.CreateLogger<ApiTokenCacheClient>(); _cache = cache; } public async Task<string> GetApiToken(string api_name, string api_scope, string secret) { var accessToken = GetFromCache(api_name); if (accessToken != null) { if (accessToken.ExpiresIn > DateTime.UtcNow) { return accessToken.AccessToken; } else { // remove => NOT Needed for this cache type } } _logger.LogDebug($"GetApiToken new from STS for {api_name}"); // add var newAccessToken = await getApiToken( api_name, api_scope, secret); AddToCache(api_name, newAccessToken); return newAccessToken.AccessToken; } private async Task<AccessTokenItem> getApiToken(string api_name, string api_scope, string secret) { try { var disco = await HttpClientDiscoveryExtensions.GetDiscoveryDocumentAsync( _httpClient, _authConfigurations.Value.StsServer); if (disco.IsError) { _logger.LogError($"disco error Status code: {disco.IsError}, Error: {disco.Error}"); throw new ApplicationException($"Status code: {disco.IsError}, Error: {disco.Error}"); } var tokenResponse = await HttpClientTokenRequestExtensions.RequestClientCredentialsTokenAsync(_httpClient, new ClientCredentialsTokenRequest { Scope = api_scope, ClientSecret = secret, Address = disco.TokenEndpoint, ClientId = api_name }); if (tokenResponse.IsError) { _logger.LogError($"tokenResponse.IsError Status code: {tokenResponse.IsError}, Error: {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}"); 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, JsonConvert.SerializeObject(accessTokenItem), options); } } private AccessTokenItem GetFromCache(string key) { var item = _cache.GetString(key); if (item != null) { return JsonConvert.DeserializeObject<AccessTokenItem>(item); } return null; } } }
This improves the performance and reduces the amount of HTTP calls for each request. The tokens for the API services are only updated when the tokens expire, and so saves many HTTP calls.
Links
https://docs.microsoft.com/en-gb/aspnet/core/mvc/overview
https://docs.microsoft.com/en-gb/aspnet/core/security/anti-request-forgery
https://docs.microsoft.com/en-gb/aspnet/core/security/
https://www.owasp.org/images/b/b0/Best_Practices_WAF_v105.en.pdf
https://tools.ietf.org/html/rfc7662
http://docs.identityserver.io/en/release/quickstarts/5_hybrid_and_api_access.html
https://github.com/aspnet/Security
http://openid.net/specs/openid-connect-core-1_0.html#HybridFlowAuth
https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed?view=aspnetcore-2.2