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:
- Identity configuration, is prepared at runtime, by a specific class named
IdentityClientAndResourcesSeedData
. - In host startup, I execute an method that initialises database with initial data.
- Seed class deletes old data, and recreates. Doing so, if configuration changed in
appSettings.json
, changes will be applied into database. - Creating custom data store classes.
- Modifying Identity Server registration: stores included.
- 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; } }
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();
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(); } }
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; } }
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); } }
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>();
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); }; };
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>(); // ...
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(); } }
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/
Top comments (0)