Manejo de proveedores de identidad en runtime

2020-10-29
7 minutos de lectura

Por favor tener en cuenta que necesitas saber de antemano cómo funciona el servicio de autenticación externa de .NET Core.

.NET Core 3.1 va a ser usado a lo largo del artículo.


¿Qué puedo lograr con este post?

Vas a poder configurar dinámicamente tus proveedores OIDC en el servicio de autenticación que viene de fábrica con .NET Core, sin tener que especificarlos todos al momento de compilar.

El artículo se centra en proveedores que usen el protocolo OpenId Connect (OIDC), como es el caso de Google o IdentityServer. No sirve para proveedores como Facebook, para el cual existe un handler que no está basado en el OpenIdConnectHandler, sino en una implementación independiente de IAuthenticationHandler.

dynamic-identity-providers-demo

Introducción

Los handlers de autenticación de .NET Core permiten usar varios proveedores externos como Google, Facebook, Twitter y cualquier proveedor OIDC con solo tocar unas pocas líneas de configuración, quedando así:

Startup.cs
//...
public void ConfigureServices(IServiceCollection services)
{
   //...
   services
      .AddAuthentication("LocalCookie")
      .AddCookie("LocalCookie")
      .AddCookie("ExternalCookie")
      // Forma genérica de registrar el login social de google, en
      // lugar de usar el método de conveniencia .AddGoogle().
      .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";
      });
   //...
}
//...

Aunque esto funciona bien de entrada y es posible registrar muchos proveedores al mismo tiempo, hay una limitación: todos los proveedores de identidad deben estar registrados al compilar, lo cual es un problema si estás desarrollando una aplicación que necesita más flexibilidad durante la ejecución.

Como una empresa SaaS, esto podría ser un problema mayor si nuestro negocio toma empresas enteras como clientes del producto.

En un caso así, muchos clientes van a necesitar poder usar su SSO corporativo para ingresar en nuestros portales y, amenos que re-desplegar la aplicacion cada vez que tenemos un nuevo cliente te suene divertido, necesitamos un sistema que soporte este caso por si mismo.

Es posible convertir esta solución rígida en una dinámica, usando nuestro store registrado como servicio en el contenedor de dependencias y registrando también implementaciones propias para algunos de los servicios que .NET Core registra en 2do plano por nosotros, de esta forma se integraría el nuevo store al pipeline de autenticación.

Creación del IOidcProviderStore

La interfaz IOidcProviderStore va a ser la usada para crear y recuperar nuestros proveedores OIDC, alimentando el servicio de configuración propio que haremos más adelante.

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();
}

Para ahorrar tiempo, esta implementación es simplemente un store en memoria sin verificar parámetros ni cuidar la thread safety, pero idealmente debería conectar, por ejemplo, con una base de datos.

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;
   }
}

Adaptando el framework para que use el nuevo store

Para que el framework realmente empiece a usar el store que creamos en lugar de las configuraciones hardcodeadas, es necesario sobreescribir 2 servicios que .NET Core registró por nosotros en 2do plano en el contenedor de dependencias:

  • IPostConfigureOptions<OpenIdConnectOptions>
  • IAuthenticationSchemeProvider

Implementación de IPostConfigureOptions<OpenIdConnectOptions>

Debemos implementar un solo método, que recibe como parámetros el nombre del proveedor actual (el nombre del auth scheme va a ser tomado como OidcProviderId en esta solución) y un objeto de configuración (pre-creado por el framework):

public void PostConfigure (string name, TOptions options);

Esta implementación pide el store de proveedores inyectado en su constructor y lo usa en cada ejecución del método PostConfigure para modificar el objeto de configuración con la información recuperada dinámicamente:

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");
         }
      }
   }
}

El framework espera que este servicio sea registrado como singleton, lo que no permite inyectar servicios scoped en él. Al registrar tu propia implementación podes usar el scope que necesites, pero debe ser singleton o transient, ya que el framework va a consumirlo en 2do plano desde servicios singleton.

Implementación de IAuthenticationSchemeProvider

Bien, ahora la aplicación puede configurar dinámicamente el handler de autenticación OIDC, dado un nombre de scheme puntual (el OidcProviderId). El problema es que el framework no puede proveer este nombre de scheme si no sabe que existe, éste es el problema que resuelve nuestra implementación propia de IAuthenticationSchemeProvider:

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;
      }
   }
}

Combinándolo todo

Para que algo de lo que hicimos tenga efecto, debemos registrar las implementaciones en el contenedor de dependencias, en nuestro archivo startup.cs, sobreescribiendo implícitamente los registros que hizo el framework por su cuenta. En nuestro ejemplo, el archivo queda así:

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());
      }
   }
}

Ejemplo en funcionamiento

La aplicación MVC demostrativa puede clonarse desde este repositorio de GitHub.