Identity Server 4 and RavenDb 5: Resource store

gabrielbarcelos

Gabriel Barcelos | C#, AspNetCore, ReactJS

Posted on November 20, 2020

Identity Server 4 and RavenDb 5: Resource store

When deploying an identity server to production, it's recommended to port Identity Resources, Api Scopes and Clients to a database.

This was my strategy:

  1. Identity configuration, is prepared at runtime, by a specific class named IdentityClientAndResourcesSeedData.
  2. In host startup, I execute an method that initialises database with initial data.
  3. Seed class deletes old data, and recreates. Doing so, if configuration changed in appSettings.json, changes will be applied into database.
  4. Creating custom data store classes.
  5. Modifying Identity Server registration: stores included.
  6. Modifying RavenDB conventions, to deal with IdentityResources.OpenId, IdentityResources.Profile, IdentityResources.Email.

1. Identity configuration class:

public class IdentityClientAndResourcesSeedData
    {
        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
                new IdentityResources.Email()
            };
        }

        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource("myApi", "API BACKEND")
                {
                    Scopes = new List<string>()
                    {
                        "myApi"
                    }
                }
            };
        }

        public static IEnumerable<ApiScope> GetApiScopes()
        {
            return new[]
            {
                new ApiScope(name: "myApi.access", displayName: "Acessar API")
            };
        }

        // clients want to access resources (aka scopes)
        public static IEnumerable<Client> GetMainClients(IConfiguration configuration)
        {
            var clientList = new List<Client>();

            /* Config MVC Client */
            var mvcClientConfig = new IdentityServerClientConfig();
            configuration.Bind("IdentityServerClients:MvcClient", mvcClientConfig);

            clientList.Add(
                // OpenID Connect hybrid flow client (MVC)
                new Client
                {
                    ClientId = mvcClientConfig,
                    ClientName = MVCClient",
                    AllowedGrantTypes = GrantTypes.Code,

                    ClientSecrets = {new Secret(mvcClientConfig.ClientSecret.Sha256())},

                    RedirectUris = {$"{mvcClientConfig.ClientUrl}/signin-oidc"},
                    PostLogoutRedirectUris =
                        {$"{mvcClientConfig.ClientUrl}/signout-callback-oidc"},
                    RequireConsent = false,
                    RequirePkce = false,

                    AllowedScopes =
                    {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.Email,
                        "myApi.access",
                        "offline_access"
                    },
                    AllowOfflineAccess = true
                }
            );

// ... insert other necessary clients here

            return clientList;
        }
    }
Enter fullscreen mode Exit fullscreen mode

2. Program.cs:

var hostBuilded = CreateWebHostBuilder(args)
                    .Build();

// Tip from: https://dotnetthoughts.net/seed-database-in-aspnet-core/
using (var scope = hostBuilded.Services.CreateScope())
{
   Log.Information("DATA SEED: will start!");
   DataSeeder.Initialize(scope.ServiceProvider).Wait();
   Log.Information("DATA SEED: ended.");
}

hostBuilded.Run();
Enter fullscreen mode Exit fullscreen mode

3. Seed:

public static class DataSeeder
    {
        public static async Task Initialize(IServiceProvider serviceProvider)
        {
            var configuration = serviceProvider.GetRequiredService<IConfiguration>();
            using var dbSession = serviceProvider.GetRequiredService<IAsyncDocumentSession>();

            // 1. Identity Resources
            var identityResourcesToSeed =
                IdentityClientAndResourcesSeedData.GetIdentityResources();

            foreach (var item in identityResourcesToSeed)
            {
                var preExistingItem = await dbSession.Query<IdentityResource>()
                    .Where(wh => wh.Name == item.Name)
                    .FirstOrDefaultAsync();

                if (preExistingItem != null)
                {
                    // deletes
                    dbSession.Delete(preExistingItem);
                }

                await dbSession.StoreAsync(item);
            }

            // 2. Api Resources
            var apiResourcesToSeed =
                IdentityClientAndResourcesSeedData.GetApiResources();

            foreach (var item in apiResourcesToSeed)
            {
                var preExistingItem = await dbSession.Query<ApiResource>()
                    .Where(wh => wh.Name == item.Name)
                    .FirstOrDefaultAsync();

                if (preExistingItem != null)
                {
                    // deletes
                    dbSession.Delete(preExistingItem);
                }

                await dbSession.StoreAsync(item);
            }

            // 3. Api Scopes
            var apiScopesToSeed =
                IdentityClientAndResourcesSeedData.GetApiScopes();

            foreach (var item in apiScopesToSeed)
            {
                var preExistingItem = await dbSession.Query<ApiScope>()
                    .Where(wh => wh.Name == item.Name)
                    .FirstOrDefaultAsync();

                if (preExistingItem != null)
                {
                    // deletes
                    dbSession.Delete(preExistingItem);
                }

                await dbSession.StoreAsync(item);
            }

            // 4. Identity Clients
            var mainClientsToSeed =
                IdentityClientAndResourcesSeedData.GetMainClients(configuration);

            foreach (var item in mainClientsToSeed)
            {
                var preExistingItem = await dbSession.Query<Client>()
                    .Where(wh => wh.ClientId == item.ClientId)
                    .FirstOrDefaultAsync();

                if (preExistingItem != null)
                {
                    // deletes
                    dbSession.Delete(preExistingItem);
                }

                await dbSession.StoreAsync(item);
            }

            await dbSession.SaveChangesAsync();
        }
    }
Enter fullscreen mode Exit fullscreen mode

4. Creating ClientStore and Identity Store

These stores will be the layer between Identity Server and RavenDB database.

4.1 Client Store

public class ClientStore : IClientStore
    {
        private readonly IAsyncDocumentSession _dbSession;

        public ClientStore(
            IAsyncDocumentSession dbSession
            )
        {
            _dbSession = dbSession;
        }

        public async Task<Client> FindClientByIdAsync(string clientId)
        {
            var clientFound =
                await _dbSession.Query<Client>()
                    .Where(wh => wh.ClientId == clientId)
                    .FirstOrDefaultAsync();

            return clientFound;
        }
    }
Enter fullscreen mode Exit fullscreen mode

4.2 Resource Store

public class ResourceStore : IResourceStore
    {
        private readonly IAsyncDocumentSession _dbSession;

        public ResourceStore(
            IAsyncDocumentSession dbSession
            )
        {
            _dbSession = dbSession;
        }

        public async Task<IEnumerable<IdentityResource>> FindIdentityResourcesByScopeNameAsync(
            IEnumerable<string> scopeNames)
        {
            if (scopeNames == null) throw new ArgumentNullException(nameof(scopeNames));

            var _identityResources =
                await _dbSession.Query<IdentityResource>().ToListAsync();

            var identity = from i in _identityResources
                where scopeNames.Contains(i.Name)
                select i;

            return identity;
        }

        public async Task<IEnumerable<ApiScope>> FindApiScopesByNameAsync(IEnumerable<string> scopeNames)
        {
            if (scopeNames == null) throw new ArgumentNullException(nameof(scopeNames));

            var _apiScopes =
                await _dbSession.Query<ApiScope>().ToListAsync();

            var query =
                from x in _apiScopes
                where scopeNames.Contains(x.Name)
                select x;

            return query;
        }

        public async Task<IEnumerable<ApiResource>> FindApiResourcesByScopeNameAsync(IEnumerable<string> scopeNames)
        {
            if (scopeNames == null) throw new ArgumentNullException(nameof(scopeNames));

            var allData =
                await _dbSession.Query<ApiResource>().ToListAsync();

            var query = from a in allData
                where a.Scopes.Any(x => scopeNames.Contains(x))
                select a;

            return query;
        }

        public async Task<IEnumerable<ApiResource>> FindApiResourcesByNameAsync(
            IEnumerable<string> apiResourceNames
            )
        {
            if (apiResourceNames == null) throw new ArgumentNullException(nameof(apiResourceNames));

            var allData =
                await _dbSession.Query<ApiResource>().ToListAsync();

            var query = from a in allData
                where apiResourceNames.Contains(a.Name)
                select a;

            return query;
        }

        public async Task<Resources> GetAllResourcesAsync()
        {
            var allApiResources =
                await _dbSession.Query<ApiResource>().ToListAsync();
            var allApiScopes =
                await _dbSession.Query<ApiScope>().ToListAsync();
            var allIdentityResources =
                await _dbSession.Query<IdentityResource>().ToListAsync();

            return new Resources(allIdentityResources, allApiResources, allApiScopes);
        }
    }
Enter fullscreen mode Exit fullscreen mode

5. Startup.cs > Registering Identity Server resource and client stores

var builder = services.AddIdentityServer()
// Add Client Store and Resource Store implementations
   .AddClientStore<ClientStore>()
   .AddResourceStore<ResourceStore>()
   // Disable InMemory additions if they were being used.
   // .AddInMemoryIdentityResources( IdentityDevelopmentConfig.GetIdentityResources())
   // .AddInMemoryApiResources(IdentityDevelopmentConfig.GetApiResources())
   // .AddInMemoryApiScopes(IdentityDevelopmentConfig.GetApiScopes())
   // .AddInMemoryClients(IdentityDevelopmentConfig.GetMainClients(configuration))
  .AddAspNetIdentity<AppUser>();
Enter fullscreen mode Exit fullscreen mode

6. RavenDB registration > Override DocumentStore to correctly define a collection name to IdentityResources types:

options.BeforeInitializeDocStore += docStoreOverride =>
                    {

                        docStoreOverride.Conventions.FindCollectionName = type =>
                        {
                            var identityResourcesTypes = new Type[]
                            {
                                typeof(IdentityResources.OpenId),
                                typeof(IdentityResources.Profile),
                                typeof(IdentityResources.Email)
                            };

                            if (identityResourcesTypes.Contains(type))
                                return "IdentityResources";

                            return DocumentConventions.DefaultGetCollectionName(type);
                        };
                    };
Enter fullscreen mode Exit fullscreen mode

These steps should work.
They cover the changes will need to do to make RavenDB the official data store for your identity server resources and clients.

šŸ’” The Data Seed implementation used in this tutorial is very useful for another scenarios.

If you have any problems let me know in comments. :)

Edit: 11/27/2020 - Persisted grant store implemented

var builder = services.AddIdentityServer(
config =>
   {
// ...
       .AddClientStore<ClientStore>()
       .AddResourceStore<ResourceStore>()
       .AddPersistedGrantStore<PersistedGrantStore>()
       .AddAspNetIdentity<AppUser>();
// ...
Enter fullscreen mode Exit fullscreen mode
public class PersistedGrantStore : IPersistedGrantStore
{
    private readonly IAsyncDocumentSession _dbSession;

    public PersistedGrantStore(
        IAsyncDocumentSession dbSession
        )
    {
        _dbSession = dbSession;
    }
    public async Task StoreAsync(PersistedGrant grant)
    {
        await _dbSession.StoreAsync(grant);

        await _dbSession.SaveChangesAsync();
    }

    public Task<PersistedGrant> GetAsync(string key)
    {
        return _dbSession.Query<PersistedGrant>()
            .Where(wh => wh.Key == key)
            .FirstOrDefaultAsync();
    }

    public Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter)
    {
        var qry = _dbSession.Query<PersistedGrant>();

        if (filter.Type.IsNullOrEmpty() == false)
            qry = qry.Where(wh => wh.Type == filter.Type);

        if (filter.ClientId.IsNullOrEmpty() == false)
            qry = qry.Where(wh => wh.ClientId == filter.ClientId);

        if (filter.SessionId.IsNullOrEmpty() == false)
            qry = qry.Where(wh => wh.SessionId == filter.SessionId);

        if (filter.SubjectId.IsNullOrEmpty() == false)
            qry = qry.Where(wh => wh.SubjectId == filter.SubjectId);

        return Task.FromResult(qry.AsEnumerable());
    }

    public async Task RemoveAsync(string key)
    {
        var objToDelete = 
            await this.GetAsync(key);
        _dbSession.Delete(objToDelete);

        await _dbSession.SaveChangesAsync();
    }

    public async Task RemoveAllAsync(PersistedGrantFilter filter)
    {
        var qry = _dbSession.Query<PersistedGrant>();

        if (filter.Type.IsNullOrEmpty() == false)
            qry = qry.Where(wh => wh.Type == filter.Type);

        if (filter.ClientId.IsNullOrEmpty() == false)
            qry = qry.Where(wh => wh.ClientId == filter.ClientId);

        if (filter.SessionId.IsNullOrEmpty() == false)
            qry = qry.Where(wh => wh.SessionId == filter.SessionId);

        if (filter.SubjectId.IsNullOrEmpty() == false)
            qry = qry.Where(wh => wh.SubjectId == filter.SubjectId);

        var grantsToRemove = await qry.ToListAsync();

        foreach (var grant in grantsToRemove)
        {
            _dbSession.Delete(grant);    
        }

        await _dbSession.SaveChangesAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

References:

https://github.com/IdentityServer/IdentityServer4/blob/18897890ce2cb020a71b836db030f3ed1ae57882/src/IdentityServer4/src/Stores/InMemory/InMemoryResourcesStore.cs
http://docs.identityserver.io/en/latest/topics/deployment.html
https://ravendb.net/docs/article-page/5.0/NodeJs/client-api/session/configuration/how-to-customize-collection-assignment-for-entities#session-how-to-customize-collection-assignment-for-entities
https://dotnetthoughts.net/seed-database-in-aspnet-core/

šŸ’– šŸ’Ŗ šŸ™… šŸš©
gabrielbarcelos
Gabriel Barcelos | C#, AspNetCore, ReactJS

Posted on November 20, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related