Skip to content

Instantly share code, notes, and snippets.

@jstclair
Last active December 28, 2020 07:10
Show Gist options
  • Save jstclair/36b108442c08e90cb20cd518f9ec1d7c to your computer and use it in GitHub Desktop.
Save jstclair/36b108442c08e90cb20cd518f9ec1d7c to your computer and use it in GitHub Desktop.
Example of wrapping IdentityServer token client with caching
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Api.Services;
using IdentityModel.Client;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Extensions.Http;
namespace Api.Infrastructure.Installers
{
public static class TokenClientInstaller
{
public static void AddCachedTokenClient(this IServiceCollection services, IConfigurationSection config)
{
// NOTE: adds an `IOptions<TokenClientSettings>` in DI so we can avoid manually configuring
// constructor injection
services.Configure<TokenClientSettings>(options =>
{
options.ClientId = config["ClientId"];
options.ClientSecret = config["ClientSecret"];
options.Scope = config["Scopes"];
});
// NOTE: Dependency for new `<[Caching|Cached]TokenHandler>` (instead of manually configuration of caching)
services.Configure<MemoryDistributedCacheOptions>(options => { });
services.AddSingleton<IDistributedCache, MemoryDistributedCache>();
// NOTE: Adds an `IOptions<CachingTokenHandlerOptions>` in DI so we can avoid manually configuring
// constructor injection for `CachingTokenHandler`
services.Configure<CachingTokenHandlerOptions>(options =>
{
options.AdjustExpirationBy = TimeSpan.FromMinutes(2);
options.CacheKey = "cached_token";
});
// NOTE: DelegatingHandlers *must* be registered as Transients, but pull in `IDistributedCache` for caching
// NOTE: delegating handler for token client (to cache resulting client credentials token)
services.AddTransient<CachingTokenHandler>();
// NOTE: delegating handler for consuming http clients that need a cached client-credentials token
services.AddTransient<CachedTokenHandler>();
// NOTE: replacement for `CachingTokenClient` that handles caching via DelegatingHandler rather than internally
services.AddHttpClient<TokenClient>(httpClient =>
{
httpClient.BaseAddress = new Uri(config["Authority"]);
})
.AddHttpMessageHandler<CachingTokenHandler>()
// and can handle transient errors automatically...
.AddTransientHttpErrorPolicy(builder => builder.WaitAndRetryAsync(new[]
{
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(7),
}));
}
}
// NOTE: extension method to make registering http clients that need a cached client-credentials token easier
public static class HttpClientExtensions
{
public static IHttpClientBuilder AddHttpClientWithClientCredentials<TClient, TImplementation>(this IServiceCollection services, Action<IServiceProvider, HttpClient> configureClient)
where TClient : class
where TImplementation : class, TClient
{
return services.AddHttpClient<TClient, TImplementation>(configureClient).AddHttpMessageHandler<CachedTokenHandler>();
}
}
public static class ExampleConsumerClientInstaller
{
public static void AddClient(this IServiceCollection services, IConfigurationSection config)
{
services.AddHttpClientWithClientCredentials<IGdprAuditClient, GdprAuditClient>((factory, httpClient) =>
{
httpClient.BaseAddress = new Uri(config["BaseUrl"]);
})
.SetHandlerLifetime(TimeSpan.FromMinutes(15))
.AddPolicyHandler(builder => builder.WaitAndRetryAsync(new[] { TimeSpan.FromSeconds(1) }));
}
}
public class TokenClientSettings
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public string Scope { get; set; }
}
/// <summary>
/// Simple version of existing `CachingTokenClient` when all the caching and error handling is externalized
/// </summary>
public class TokenClient
{
public TokenClient(HttpClient client, IOptions<TokenClientSettings> options)
{
Client = client;
Options = options.Value;
}
public HttpClient Client { get; }
public TokenClientSettings Options { get; }
public async Task<(string, TimeSpan)> GetToken()
{
// NOTE: DelegatingHandler pipeline runs from **HERE** ...
var response = await Client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = "/idsrv/connect/token",
ClientId = Options.ClientId,
ClientSecret = Options.ClientSecret,
Scope = Options.Scope
});
// NOTE: ... to **HERE**
if (response.IsError) throw response.Exception;
var token = response.AccessToken;
var expirationTimeSpan = TimeSpan.FromSeconds(response.ExpiresIn);
return (token, expirationTimeSpan);
}
}
/// <summary>
/// A DelegatingHandler for any HttpClient that needs a client-credentials token
/// </summary>
public class CachedTokenHandler : DelegatingHandler
{
private readonly TokenClient _tokenClient;
public CachedTokenHandler(TokenClient tokenClient) => _tokenClient = tokenClient;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
var (token, _) = await _tokenClient.GetToken();
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await base.SendAsync(request, cancellationToken);
}
}
public class CachingTokenHandlerOptions
{
public TimeSpan AdjustExpirationBy { get; set; } = TimeSpan.FromMinutes(2);
public string CacheKey { get; set; } = "cached_token";
}
/// <summary>
/// A DelegatingHandler for caching the results of TokenClient. We wrap the existing call to get a token with the
/// distributed cache lookup/store. Since we return early if we have a valid token, the TokenClient's call
/// to Identity Server is never made.
/// </summary>
public class CachingTokenHandler : DelegatingHandler
{
private readonly IDistributedCache _distributedCache;
private readonly CachingTokenHandlerOptions _options;
public CachingTokenHandler(IDistributedCache distributedCache, IOptions<CachingTokenHandlerOptions> options)
{
_distributedCache = distributedCache;
_options = options.Value ?? new CachingTokenHandlerOptions();
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
CancellationToken cancellationToken)
{
var token = await _distributedCache.GetStringAsync(_options.CacheKey, cancellationToken);
if (token != null)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(token)
};
}
var result = await base.SendAsync(request, cancellationToken);
var content = await result.Content.ReadAsStringAsync();
var tr = new TokenResponse(content);
var newExpiration = TimeSpan.FromSeconds(tr.ExpiresIn) - _options.AdjustExpirationBy;
await _distributedCache.SetStringAsync(_options.CacheKey, content, new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = newExpiration
}, cancellationToken);
// NOTE: can't return original result because we have read the stream, so create a new one
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(content)
};
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment