Identity Server 4 and RavenDb 5: Resource store
Gabriel Barcelos | C#, AspNetCore, ReactJS
Posted on November 20, 2020
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/
Posted on November 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.