Dynamic OIDC identity provider management

2020-10-29
7 min read
.NET , C#

Please note that you already need to know how to setup external authentication with .NET Core to take full advantage of this article.

.NET Core 3.1 will be used in this post.


What does this post help me to do?

You’ll be able to dynamically configure your OIDC providers in the .NET Core’s out-of-the-box Authentication service, without being forced to list them on compile-time.

The article focuses on OpenId Connect (OIDC) providers only, like Google or IdentityServer. It won’t work for a provider like Facebook, which C# client implementation is not based on the framework's OpenIdConnectHandler, but it’s own IAuthenticationHandler implementation.

dynamic-identity-providers-demo

Introduction

.NET Core’s authentication handlers support many external providers such as Google, Facebook, Twitter, and any random OIDC provider by just tweaking a few lines, with a service configuration that looks like this:

Startup.cs
//...
public void ConfigureServices(IServiceCollection services)
{
   //...
   services
      .AddAuthentication("LocalCookie")
      .AddCookie("LocalCookie")
      .AddCookie("ExternalCookie")
      // Generic way of registering the google social login, instead of using the
      // .AddGoogle() convenience extension method.
      .AddOpenIdConnect("google", conf =>
      {
         conf.SignInScheme = "ExternalCookie";
         conf.Authority = "https://accounts.google.com";
         conf.ClientId = "<VALID CLIENT ID>";
         conf.ClientSecret = "<VALID CLIENT SECRET>";
         conf.ResponseType = "code";

         conf.CallbackPath = "/signin-oidc-google";
         conf.SignedOutCallbackPath = "/signout-oidc-google";
      });
   //...
}
//...

Although this feature is great out of the box and you can have many authentication schemes registered, this does have a downside: all identity providers must be registered at compile time, which is a problem if you are developing an application needs some runtime flexibility.

As a SaaS company, this might even be a deal-breaker if our business targets entire companies as customers.

In that case, many of them will require to be able to use their corporate SSO to login into our product and, unless re-deploying each time we get a new customer sounds fun to you, we need the system to support this organically.

It’s possible to convert this rigid authentication handling approach into a dynamic one, by using our own store registered as a service in the dependency container and registering custom implementations for some of the already existing .NET Core services, so the custom providers store is used by the authentication pipeline.

Creation of the IOidcProviderStore

The IOidcProviderStore interface is new and we’re making it up right now, this whole process will involve some new interfaces and some framework provided ones so it’s easy to get lost if you’re not familiar with all of this similar-sounding names.

This interface will be used to create and retrieve our OIDC providers, feeding the custom configuration service we’ll create later on.

IOidcProviderStore.cs
public interface IOidcProviderStore
{
   Task<OidcProvider> GetById(string oidcProviderId);
   Task<IEnumerable<OidcProvider>> GetAll();
   Task Create(OidcProvider newProvider);
   Task Delete(string oidcProviderId);
   Task Update(OidcProvider updatedProvider);
   Task<bool> IsExisting(string oidcProviderId);
   Task<IEnumerable<string>> GetAllIds();
}

To save some time, the store implementation will be a simple in-memory store without any parameter guarding or thread safety, but ideally it should connect to a persistent store, like a database.

OidcProviderInMemoryStore.cs
public class OidcProviderInMemoryStore : IOidcProviderStore
{
   private readonly List<OidcProvider> _providers;
   private static readonly object _lock = new object();

   public OidcProviderInMemoryStore(List<OidcProvider> providers)
   {
      _providers = providers;
   }

   public async Task Create(OidcProvider newProvider)
   {
      _providers.Add(newProvider);
   }

   public async Task<OidcProvider> GetById(string oidcProviderId)
   {
      OidcProvider foundProvider =
         _providers.FirstOrDefault(x => x.OidcProviderId == oidcProviderId);

      return foundProvider;
   }

   public async Task Delete(string oidcProviderId)
   {
      int foundProviderIdx =
         _providers.FindIndex(x => x.OidcProviderId == oidcProviderId);

      if(foundProviderIdx != -1)
         _providers.RemoveAt(foundProviderIdx);
   }

   public async Task<bool> IsExisting(string oidcProviderId)
   {
      bool isExisting =
         _providers.Any(x => x.OidcProviderId == oidcProviderId);

      return isExisting;
   }

   public async Task<IEnumerable<string>> GetAllIds()
   {
      IEnumerable<string> allProviderIds =
         _providers.Select(x => x.OidcProviderId);

      return allProviderIds;
   }

   public async Task<IEnumerable<OidcProvider>> GetAll()
   {
      return _providers;
   }

   public async Task Update(OidcProvider updated)
   {
      OidcProvider found =
         _providers
         .FirstOrDefault(x => x.OidcProviderId == updated.OidcProviderId);

      found.AuthorityUrl = updated.AuthorityUrl;
      found.ClientId = updated.ClientId;
      found.ClientSecret = updated.ClientSecret;
      found.ExpectedResponseType = updated.ExpectedResponseType;
      found.Name = updated.Name;
      found.RequireHttpsMetadata = updated.RequireHttpsMetadata;
      found.ScopesToRequest = updated.ScopesToRequest;
   }
}

Adapting the framework to use the new store

For the framework to actually start using the provider store instead of the hard-coded ones, we’ll need to override 2 services already registered by .NET Core in the dependency container:

  • IPostConfigureOptions<OpenIdConnectOptions>
  • IAuthenticationSchemeProvider

Implementing IPostConfigureOptions<OpenIdConnectOptions>

We’ll need to implement a single method that receives the name of the current provider (the scheme name will be considered the OidcProviderId in this approach) and a placeholder configuration object (instantiated by the framework) as parameters:

public void PostConfigure (string name, TOptions options);

This implementation gets the provider store through constructor injection and uses it on each PostConfigure method call to modify the configuration object with the dynamically fetched data:

MyOidcPostconfigurator.cs
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
using System;
using System.Threading.Tasks;

namespace DynamicOidcProviders
{
   public class MyOidcPostconfigurator : IPostConfigureOptions<OpenIdConnectOptions>
   {
      private readonly IOidcProviderStore _oidcProviderStore;

      public MyOidcPostconfigurator(IOidcProviderStore oidcProviderStore)
      {
         _oidcProviderStore = oidcProviderStore;
      }

      public void PostConfigure(string name, OpenIdConnectOptions options)
      {
         OidcProvider provider =
            Task.Run<OidcProvider>(async () => {
               OidcProvider foundProvider =
                  await _oidcProviderStore.GetById(name);

               return foundProvider;
            })
            .Result;

         if (provider != null)
         {
            options.SignInScheme = "ExternalCookie";
            options.Authority = provider.AuthorityUrl;
            options.ClientId = provider.ClientId;
            options.ClientSecret = provider.ClientSecret;
            options.ResponseType = provider.ExpectedResponseType;
            options.RequireHttpsMetadata = provider.RequireHttpsMetadata;

            // Callback paths must be unique per provider
            options.CallbackPath =
               $"/callbacks/oidc/{provider.OidcProviderId}/signin";

            options.SignedOutCallbackPath =
               $"/callbacks/oidc/{provider.OidcProviderId}/signout";

            options.Events = new OpenIdConnectEvents
            {
               OnRemoteFailure = async context =>
               {
                  context.Response.Redirect("/");
                  context.HandleResponse();
               }
            };
         }
         else
         {
            throw new InvalidOperationException(
               "Trying to use an unexisting OIDC provider");
         }
      }
   }
}

The framework will expect this service to be registered as a singleton, which does not allow for scoped services to be injected into it. When registerint your own implementation, you can use a scope that fits your needs, but it must be either singleton or transient, since it will be injected into other framework’s services under the hood and many of them are singletons.

Implementing IAuthenticationSchemeProvider

Ok, now the app can dynamically configure it’s OIDC authentication request handler, given an authentication scheme’s name (OidcProviderId). The problem is that the framework can’t provide this scheme name if it doesn’t know it even exists, and that problem is solved with our custom IAuthenticationSchemeProvider implementation:

MyAuthenticationSchemeProvider.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace DynamicOidcProviders
{
   public class MyAuthenticationSchemeProvider : AuthenticationSchemeProvider
   {
      private readonly IOidcProviderStore _oidcProviderStore;
      private readonly Type _oidcHandlerType;

      public MyAuthenticationSchemeProvider(
            IOptions<AuthenticationOptions> options,
            IOidcProviderStore oidcProviderStore)
         : base(options)
      {
         this._oidcProviderStore = oidcProviderStore;

         if (this._oidcProviderStore == null)
         {
            throw new ArgumentNullException(
               $"An implementation of {typeof(IOidcProviderStore).FullName} must " +
               "be registered in the dependency container");
         }

         this._oidcHandlerType = typeof(OpenIdConnectHandler);
      }

      public override async Task<IEnumerable<AuthenticationScheme>> GetRequestHandlerSchemesAsync()
      {
         List<AuthenticationScheme> schemeList = new List<AuthenticationScheme>();

         IEnumerable<string> dynamicOidcProviderIds =
            await _oidcProviderStore.GetAllIds();

         foreach (string oidcProviderId in dynamicOidcProviderIds)
         {
            schemeList.Add(
               new AuthenticationScheme(
                  oidcProviderId, oidcProviderId, _oidcHandlerType));
         }

         return schemeList;
      }

      public override async Task<AuthenticationScheme> GetSchemeAsync(string name)
      {
         AuthenticationScheme scheme = await base.GetSchemeAsync(name);

         if (scheme == null)
         {
            bool isExisting = await _oidcProviderStore.IsExisting(name);

            if (isExisting)
               scheme = new AuthenticationScheme(name, name, _oidcHandlerType);
         }

         return scheme;
      }

      public override async Task<IEnumerable<AuthenticationScheme>> GetAllSchemesAsync()
      {
         List<AuthenticationScheme> allSchemes = new List<AuthenticationScheme>();

         IEnumerable<AuthenticationScheme> localSchemes =
            await base.GetAllSchemesAsync();

         if (localSchemes != null)
            allSchemes.AddRange(localSchemes);

         IEnumerable<string> dynamicOidcProviderIds =
            await _oidcProviderStore.GetAllIds();

         if (dynamicOidcProviderIds != null)
         {
            foreach (string oidcProviderId in dynamicOidcProviderIds)
            {
               allSchemes.Add(
                  new AuthenticationScheme(
                     oidcProviderId, oidcProviderId, _oidcHandlerType));
            }
         }

         return allSchemes;
      }
   }
}

Putting it all together

For all of this to actually have any effect we have to register our custom implementations in the service container at our startup.cs file, implicitly overriding the ones registered by the framework under the hood. Here is an example of how a configured startup would look like:

startup.cs
using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;

namespace DynamicOidcProviders
{
   public class Startup
   {
      public IConfiguration Configuration { get; }

      public Startup(IConfiguration configuration)
      {
         Configuration = configuration;
      }

      public void ConfigureServices(IServiceCollection services)
      {
         services
            .AddControllersWithViews()
            .AddRazorRuntimeCompilation();

         // IMPORTANT: this postconfigurator MUST be registered BEFORE
         // the "AddOpenIdConnect()" call.
         services.AddTransient<
            IPostConfigureOptions<OpenIdConnectOptions>,
            MyOidcPostconfigurator>();

         services
            .AddAuthentication("LocalCookie") // *Default scheme used as login.
            .AddCookie("LocalCookie")         // *Exclusive for local login.
            .AddCookie("ExternalCookie")      // *Exclusive for external login.
            // Handler that will process ALL providers, with dynamic config
            .AddOpenIdConnect(
               "oidcHandlerHub",
               "oidcHandlerHub",
               _ => { });

         services.AddTransient<
            IAuthenticationSchemeProvider,
            MyAuthenticationSchemeProvider>();

         // Registers the in-memory provider store, seeded with the
         // configuration we  previously had in the authentication
         // service configuration.
         services.AddSingleton<IOidcProviderStore>(_ =>
            new OidcProviderInMemoryStore(
               new List<OidcProvider> {
                  new OidcProvider
                  {
                     OidcProviderId = "google",
                     Name = "Google",
                     AuthorityUrl = "https://accounts.google.com",
                     ClientId = "<VALID CLIENT ID>",
                     ClientSecret = "<VALID CLIENT SECRET>",
                     ExpectedResponseType = "code"
                  }
               }
            )
         );
      }

      public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
      {
         if (env.IsDevelopment())
         {
            app.UseDeveloperExceptionPage();
         }
         else
         {
            app.UseExceptionHandler("/Home/Error");
            app.UseHsts();
         }

         app.UseHttpsRedirection();
         app.UseStaticFiles();

         app.UseRouting();

         app.UseAuthentication();
         app.UseAuthorization();

         app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
      }
   }
}

Working example

You can clone the working MVC example application in this GitHub repo.

Next Second

See Also

Second