Last active
December 28, 2020 07:10
-
-
Save jstclair/36b108442c08e90cb20cd518f9ec1d7c to your computer and use it in GitHub Desktop.
Example of wrapping IdentityServer token client with caching
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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