ASP.NET
Core Identity
Server 4 Discovery
Document JWK
It
contains the issuer name, key material, supported scopes and endpoints (URLs)
to get JWKS (JSON
Web Key Sets) and token …etc.
However,
the discovery document wont be changed in most time, so we can cache it and
only request the Discovery
Endpoint when the cache is expired.
IdentityModel library supports caching the discovery document so that
we don’t have to do it ourselves.
This
article will shows how to
1. Cache the Discovery Document
2. Refresh the Discovery Document
in the
client side (Backend API Server) of Idsrv.
▋Docker 18.05.0-ce
▋.NET Core SDK 3.1.201
▋IdentityServer4 3.1.2
▋IdentityModel 4.0.0
The
source code is on my Github.
If you
have not created the API Server, please following this
tutorial to create one.
▋Add HttpClient factory
Of course we need a HttpClient to send request to Discovery
Endpoint for the discovery document.
Lets add HttpClient factory in Startup.cs.
▋Startup.cs:
ConfigureServices
public void ConfigureServices(IServiceCollection services)
{
// …skip
other configurations
services.AddHttpClient("AuthHttpClient",
config =>
{
config.Timeout = TimeSpan.FromMinutes(5);
config.DefaultRequestHeaders.Add("Accept", "application/json");
})
.SetHandlerLifetime(TimeSpan.FromMinutes(5)); // HttpMessageHandler lifetime = 2 min
}
▋Update Identity Client service
Interface: IIdentityClient.cs
Implement: IdentityClient.cs
▋IdentityClient.cs
public class IdentityClient : IIdentityClient
{
private const int DEFAULT_REFRESH_DISCOVERY_DOC_DURATION = 24;
private readonly IHttpClientFactory httpClientFactory = null;
private readonly DiscoveryCache discoCacheClient = null;
private DiscoveryDocumentResponse discoResponse = null;
private readonly string remoteServiceBaseUrl = string.Empty;
public IdentityClient(
IOptions<AppSettings> configuration,
ILogger<IdentityClient> logger,
IHttpClientFactory httpClientFactory)
{
this.appSettings = configuration.Value;
this.logger = logger;
this.httpClientFactory = httpClientFactory;
this.remoteServiceBaseUrl = "https://localhost:6001";
#region Create Discovery Cache client
var discoPolicy = this.remoteServiceBaseUrl.StartsWith("https") ?
null :
new DiscoveryPolicy
{
RequireHttps = false,
};
this.discoCacheClient = new DiscoveryCache(
this.remoteServiceBaseUrl,
() => this.httpClientFactory.CreateClient("AuthHttpClient"),
discoPolicy);
// Set cache duration
discoCacheClient.CacheDuration = TimeSpan.FromHours(DEFAULT_REFRESH_DISCOVERY_DOC_DURATION);
#endregion
}
▋How to get
Discovery Document
DiscoveryDocumentResponse discoResponse = await this.discoCacheClient.GetAsync();
if (discoResponse.IsError)
throw new Exception(discoResponse.Error);
By
monitoring the Http traffic, we can see that this.discoCacheClient.GetAsync()
send the request to Idsrv only the first time. After that, it returned the
cached DiscoveryDocumentResponse value. (But it will send the request to Idsrv again after
the cache is expired.)
▋Refresh Discovery Document
If we
don’t want to wait for the expiration of cached Discovery Document, we can
force updating it by calling IdentityModel.Client.DiscoveryCache’s Refresh()
method to mark the Discovery Document as outdated and will trigger a request to
the Discovery Endpoint on the next request to get the DiscoveryResponse.
public async Task RefreshDiscoveryDocAsync()
{
this.discoCacheClient.Refresh(); // this.discoCacheClient: IdentityModel.Client.DiscoveryCache
_ = await this.discoverCachedDocumentAsync();
await Task.CompletedTask;
}
This may
happens when the signing credential of Idsrv is updated, in other words, the
key-rollover happens but the client side wont get the new JWKS immediately.
In this
case, we can use the above code to get both the latest Discovery Document and
JWKS without waiting the expiration of the cache.
For
example, I create a Middleware to refresh the Discovery Document when
validating JWT fails:
▋InvalidTokenMiddleware.cs
public class InvalidTokenMiddleware
{
private readonly RequestDelegate next;
public InvalidTokenMiddleware(RequestDelegate next)
{
this.next = next;
}
public async Task InvokeAsync(HttpContext context)
{
#region Incoming
// Skip incoming request
#endregion
await this.next(context);
#region Outgoing
context.Response.Headers.TryGetValue("WWW-Authenticate", out StringValues authHeader);
if (!string.IsNullOrEmpty(authHeader))
{
switch (authHeader.ToString())
{
case string b when b.Contains("invalid_token", StringComparison.InvariantCultureIgnoreCase):
var idsrvClient = context.RequestServices.GetService<IIdentityClient>();
await idsrvClient.RefreshDiscoveryDocAsync();
break;
}
}
#endregion
}
}
Enable
the Middleware in Startup.cs: Configure,
public void Configure(IApplicationBuilder app)
{
// …skip
app.UseMiddleware<InvalidTokenMiddleware>();
}
Then the
Discovery Document will be refreshed once validating JWT fails.