2020年8月16日 星期日

[ASP.NET Core] Identity Server 4 – Cache and refresh Discovery document


 ASP.NET Core   Identity Server 4   Discovery Document   JWK  







Introduction


The discovery document is the metadata about Identity Server from the Discovery Endpoint.
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.








Environment


Docker 18.05.0-ce
.NET Core SDK 3.1.201
IdentityServer4 3.1.2
IdentityModel 4.0.0



Implement


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

In the tutorial of [ASP.NET Core] Identity Server 4 – Secure Web API, we have the Identity Client Service’s interface and implementation as following,

Interface: IIdentityClient.cs
Implement: IdentityClient.cs

We will update the exist codes to support caching on discovery document by using IdentityModel.Client.DiscoveryCache.


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.)

On the other hand, the IdentityModel.Client.HttpClientDiscoveryExtensions: GetDiscoveryDocumentAsync method will also send a request to get JWKS once the Discovery Document is ready. Thaz why there was the other request that was send to http://localhost:6001/.well-known/openid-configuration/jwks.





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.





Source Code




Reference