Search code examples
identityserver4entity-framework-migrationssustainsys-saml2

How can I add custom tables to configurationDbContext?


I am looking to customise IdentityServer4 to load external identity providers from the database. I would like to extend ConfigurationDBContext to include a DbSet of Saml2Provider. In my startup I'd like to then add the Saml2Provider automatically. Ideally, I would like an easy way for the list of providers available to be refreshed in the idsvr4 login page without having to restart the application.

I have been able to load my Saml2Providers from the DB and register them as external providers. However, this is using the ApplicationDbcontext, and it is not being refreshed on each request to idsvr.

This is my configureServices that is working (using ApplicationDbContext to retrieve providers from the DB):

public void ConfigureServices(IServiceCollection services)

    services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(connectionString));
    var builder = services.AddIdentityServer(options =>
        {
            options.Events.RaiseErrorEvents = true;
            options.Events.RaiseInformationEvents = true;
            options.Events.RaiseFailureEvents = true;
            options.Events.RaiseSuccessEvents = true;
        })
            // this adds the config data from DB (clients, resources)
            .AddConfigurationStore(options =>
            {

                options.Client.Schema = "config";
                options.DefaultSchema = "config";
                options.ConfigureDbContext = b =>
                    b.UseSqlServer(connectionString,
                        sql =>        sql.MigrationsAssembly(migrationsAssembly));
            })
            // this adds the operational data from DB (codes, tokens, consents)
            .AddOperationalStore(options =>
            {
                options.ConfigureDbContext = b => 
                    b.UseSqlServer(connectionString,
                        sql => sql.MigrationsAssembly(migrationsAssembly));

                // this enables automatic token cleanup. this is optional.
                options.EnableTokenCleanup = true;
            })
            .AddAspNetIdentity<ApplicationUser>()
            .AddProfileService<CustomProfileService>();
......
    var context = serviceProvider.GetService<ApplicationDbContext>();

    var saml2Providers = context.Saml2Providers.ToList();
     foreach(var provider in saml2Providers)
     {
        provider.RegisterService(services);
     }
}

This is my attempt at extending ConfigurationDbContext:

public class IdSrvConfigurationDbContext : ConfigurationDbContext<IdSrvConfigurationDbContext>
{
    public DbSet<Saml2Provider> Saml2Providers { get; set; }

    public IdSrvConfigurationDbContext(DbContextOptions<IdSrvConfigurationDbContext> options, ConfigurationStoreOptions storeOptions) 
        :base(options, storeOptions)            
    {

    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        //mylogic here  
        base.OnModelCreating(modelBuilder);
        modelBuilder.Entity<Saml2Provider>().ToTable("Saml2ProviderConfigContext", schema: "config");
    }
}

I am wanting the external providers to be refreshed in the login screen automatically when they are updated in the database. I also want to load the external provider information via the ConfigurationDbContext if possible, as it makes sense to be there.

There are 2 issues extending ConfigurationDbContext:

  1. Migrations don't build correctly:

    Unable to create an object of type 'IdSrvConfigurationDbContext'. Add an implementation of 'IDesignTimeDbContextFactory' to the project, or see https://go.microsoft.com/fwlink/?linkid=851728 for additional patterns supported at design time.

  2. I can't access the extended context from startup correctly. I'm not sure how to wire it up correctly.

I'm sure there is a correct way to wire this up by extending the Identity options builder, but I've no idea how to do this. Any help would be greatly appreciated.


Solution

  • I found a solution to this issue, where I could add a Scheme dynamically to the SchemeProvider.

    Reference: Dynamically add a SAML2 authentication provider using Sustainsys.Saml2 in ASP.NET Core

    https://github.com/aspnet/AuthSamples/blob/master/samples/DynamicSchemes/Controllers/AuthController.cs

    This is the challenge method on the controller, which will add a the scheme.

    [HttpGet]
        public async Task<IActionResult> Challenge(string provider, string returnUrl)
        {
            if (string.IsNullOrEmpty(returnUrl))
            {
                returnUrl = "~/";
            }
    
            // validate returnUrl - either it is a valid OIDC URL or back to a local page
            if (Url.IsLocalUrl(returnUrl) == false && interaction.IsValidReturnUrl(returnUrl) == false)
            {
                // user might have clicked on a malicious link - should be logged
                throw new Exception("invalid return URL");
            }
    
            if (provider == AccountOptions.WindowsAuthenticationSchemeName)
            {
                // windows authentication needs special handling
                return await ProcessWindowsLoginAsync(returnUrl);
            }
            else
            {
                // start challenge and roundtrip the return URL and scheme
                var props = new AuthenticationProperties
                {
                    RedirectUri = Url.Action(nameof(Callback)),
                    Items =
                    {
                        { "returnUrl", returnUrl },
                        { "scheme", provider },
                    },
                };
    
                // Checks to see if the scheme exists in the provider, then will add a new one if it's found in the database.
                await schemeProviderLoader.TryAddScheme(provider);
                return Challenge(props, provider);
            }
        }
    

    and this is a class that will lookup the scheme in the schemeprovider, and if it doesn't exist it will try and add it from the DB.

    /// <summary>
    /// Helper class to dynamically add Saml2 Providers the SchemeProvider.
    /// If the scheme is not found on the scheme provider it will look it up in the database and if found, will add it to the schemeprovider.
    /// </summary>
    public class SchemeProviderLoader
    {
        private readonly ApplicationDbContext dbContext;
        private readonly IAuthenticationSchemeProvider schemeProvider;
        private readonly IOptionsMonitorCache<Saml2Options> optionsCache;
        private readonly ILogger logger;
    
        /// <summary>
        /// Initializes a new instance of the <see cref="SchemeProviderLoader"/> class.
        /// </summary>
        /// <param name="dbContext">Database context used to lookup providers.</param>
        /// <param name="schemeProvider">SchemeProvider to add the scheme to.</param>
        /// <param name="optionsCache">Options cache to add the scheme options to.</param>
        /// <param name="logger">Logger.</param>
        public SchemeProviderLoader(ApplicationDbContext dbContext, IAuthenticationSchemeProvider schemeProvider, IOptionsMonitorCache<Saml2Options> optionsCache, ILogger<SchemeProviderLoader> logger)
        {
            this.dbContext = dbContext;
            this.schemeProvider = schemeProvider;
            this.optionsCache = optionsCache;
            this.logger = logger;
        }
    
        /// <summary>
        /// Will dynamically add a scheme after startup.
        /// If the scheme is not found on the scheme provider it will look it up in the database and if found, will add it to the schemeprovider.
        /// </summary>
        /// <param name="scheme">The name of the identity provider to add.</param>
        /// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation. True if the scheme was found or added. False if it was not found.</returns>
        public async Task<bool> TryAddScheme(string scheme)
        {
            if (await schemeProvider.GetSchemeAsync(scheme) == null)
            {
                // Lookup to see if the scheme has been added to the saml2providers since the app was last loaded.
                var saml2Provider = await dbContext.Saml2Providers.FindAsync(scheme);
                if (saml2Provider == null)
                {
                    return false;
                }
    
                // Add the scheme.
                schemeProvider.AddScheme(new AuthenticationScheme(scheme, saml2Provider.IdpCaption, typeof(Saml2Handler)));
    
                // Add saml2 options to the options cache
                Saml2Options options = new Saml2Options();
                saml2Provider.GetSaml2Options(options);
                options.SPOptions.Logger = new AspNetCoreLoggerAdapter(logger);
                optionsCache.TryAdd(scheme, options);
            }
    
            return true;
        }
    }