Identityserver Stores

作者 DuendeSoftwarefb32edc51982無授權條款9 個星標收錄於 2026年10月8日更新於 2026年10月8日儲存庫4 週前更新

Implement and customize Duende IdentityServer stores including configuration store, operational store, and Entity Framework Core integration. Covers migrations, custom store implementations, caching strategies, server-side sessions, signing key storage, token cleanup, and multi-tenant patterns.

AI 產生的概覽

指導使用 EF Core 實作與客製 Duende IdentityServer 的組態存放區與作業存放區。

功能
此技能提供 Duende IdentityServer 持久化方面的指引:使用 EF Core 註冊 AddConfigurationStore 與 AddOperationalStore、設定移轉組件與結構描述,以及建立 EF Core 移轉。內容也涵蓋針對 IClientStore、IResourceStore、IPersistedGrantStore、IServerSideSessionStore 與 ISigningKeyStore 等介面的自訂存放區實作,以及快取、伺服器端工作階段、簽章金鑰儲存、權杖清理與多租用戶模式。產出的是組態與程式碼指引,而非可直接執行的成品。
適用情境
適用於使用 EF Core 註冊 IdentityServer 存放區、針對 Redis、Mongo 或外部 API 實作自訂存放區,或調校組態存放區快取時。也適合跨 IdentityServer 版本的移轉與結構描述漂移處理、伺服器端工作階段設定、簽章金鑰輪換問題,以及多租用戶存放區設計。
執行需求
需要 Duende.IdentityServer.EntityFramework NuGet 套件,以及使用 EF Core 的 ASP.NET Core 主機;此技能本身不附帶指令碼,僅為說明性內容。部分指引假設使用 SQL Server 等資料庫提供者、用於分散式快取的 Redis,以及用於移轉的 dotnet ef 工具。

Duende IdentityServer Stores

When to Use This Skill

  • You are wiring up AddConfigurationStore() or AddOperationalStore() with EF Core and need correct registration, migration assembly setup, and schema configuration.
  • You are implementing a custom IClientStore, IResourceStore, IPersistedGrantStore, or ISigningKeyStore against a non-EF data source (Redis, Mongo, external API, etc.).
  • You need to enable and tune configuration store caching (AddConfigurationStoreCache(), expiration windows, distributed cache setup) to reduce database load.
  • You are managing EF Core migrations across IdentityServer versions and need to correctly handle schema drift for ConfigurationDbContext and PersistedGrantDbContext.
  • You are enabling server-side sessions (IServerSideSessionStore) and need to understand session lifecycle, cleanup, and storage integration.
  • You are troubleshooting stale client or resource data, expired token accumulation, or signing key rotation failures tied to store configuration.
  • You are designing a multi-tenant IdentityServer deployment and need to choose between database-per-tenant and shared-database store strategies.

Core Principles

Store interfaces decouple IdentityServer from persistence. All data access goes through store interfaces registered in the ASP.NET Core DI container. IdentityServer does not care what database backs them — EF Core, Redis, MongoDB, or a static in-memory collection are all equally valid.

Two independent store categories exist: configuration and operational. They can be used independently or together. Configuration data is relatively static (clients, resources, CORS); operational data is dynamic and high-write (grants, sessions, signing keys). They should be sized, cached, and maintained with those distinct access patterns in mind.

Operational data is protected at rest. The Data payload of persisted grants and serialized signing keys is encrypted using the ASP.NET Core Data Protection API. Key rotation and Data Protection configuration must be coordinated — a lost Data Protection key makes stored grants and signing keys unreadable.

Consumed grants are soft-deleted, not immediately removed. One-time-use grants (e.g., authorization codes, one-time refresh tokens) are marked with a ConsumedTime rather than deleted. This enables threat detection in custom IRefreshTokenService implementations. Do not confuse consumed with expired — the token cleanup service only removes records past their Expiration, not consumed ones (unless RemoveConsumedTokens is enabled).

EF Core schema changes are your responsibility. Duende does not ship automatic migration scripts or schema upgrade tooling. You own migration creation, application, and data migration between IdentityServer versions.

Docs: https://docs.duendesoftware.com/identityserver/data


NuGet Package

bash
dotnet add package Duende.IdentityServer.EntityFramework

This package provides EF Core implementations for all configuration and operational store interfaces.


Store Architecture

IdentityServer's data is split into two categories, each with its own set of store interfaces:

┌─────────────────────────────────────────────────────────────┐│                    IdentityServer Runtime                    │├──────────────────────────┬──────────────────────────────────┤│    Configuration Data    │       Operational Data           ││                          │                                  ││  • Clients               │  • Authorization codes           ││  • API Resources         │  • Reference tokens              ││  • API Scopes            │  • Refresh tokens                ││  • Identity Resources    │  • User consent                  ││  • Identity Providers    │  • Device codes                  ││  • CORS policies         │  • Pushed auth. requests         ││                          │  • Signing keys                  ││                          │  • Server-side sessions          │├──────────────────────────┼──────────────────────────────────┤│  ConfigurationDbContext   │  PersistedGrantDbContext          ││  (IClientStore,          │  (IPersistedGrantStore,           ││   IResourceStore,        │   IDeviceFlowStore,               ││   IIdentityProviderStore,│   IPushedAuthorizationRequestStore,││   ICorsPolicyService)    │   IServerSideSessionStore,        ││                          │   ISigningKeyStore)               │└──────────────────────────┴──────────────────────────────────┘

Configuration Data

Stores static, rarely-changing data that describes how IdentityServer behaves:

InterfaceContents
IClientStoreOAuth/OIDC clients (grant types, redirect URIs, secrets, claims, scopes)
IResourceStoreIdentityResource, ApiResource, and ApiScope definitions
ICorsPolicyServiceCORS allowed-origin rules (derived from client configuration)
IIdentityProviderStoreDynamic external identity provider registrations

Operational Data

Stores dynamic, high-write runtime state that IdentityServer creates and manages during request processing:

InterfaceContents
IPersistedGrantStoreAuthorization codes, refresh tokens, reference tokens, user consent records
IDeviceFlowStoreDevice authorization flow codes and user codes
ISigningKeyStoreDynamically managed signing keys (used by automatic key management)
IServerSideSessionStoreServer-side authentication session data for interactive users
IPushedAuthorizationRequestStorePushed authorization request (PAR) data

EF Core Integration

The Duende.IdentityServer.EntityFramework NuGet package provides EF Core-backed implementations of all store interfaces. It ships two DbContext types:

  • ConfigurationDbContext — backs IClientStore, IResourceStore, ICorsPolicyService, IIdentityProviderStore
  • PersistedGrantDbContext — backs IPersistedGrantStore, IDeviceFlowStore, ISigningKeyStore, IServerSideSessionStore

Registering Both Stores

csharp
// ✅ Correct: register both stores with explicit migration assemblyvar migrationsAssembly = typeof(Program).Assembly.GetName().Name;var connectionString = builder.Configuration.GetConnectionString("IdentityServer");
builder.Services.AddIdentityServer()    .AddConfigurationStore(options =>    {        options.ConfigureDbContext = b =>            b.UseSqlServer(connectionString, sql =>                sql.MigrationsAssembly(migrationsAssembly));    })    .AddOperationalStore(options =>    {        options.ConfigureDbContext = b =>            b.UseSqlServer(connectionString, sql =>                sql.MigrationsAssembly(migrationsAssembly));
        options.EnableTokenCleanup = true;        options.TokenCleanupInterval = 3600; // seconds; default 1 hour    });
csharp
// ❌ Wrong: omitting MigrationsAssembly when migrations live in the host projectbuilder.Services.AddIdentityServer()    .AddConfigurationStore(options =>    {        options.ConfigureDbContext = b => b.UseSqlServer(connectionString);        // EF will look for migrations in Duende.IdentityServer.EntityFramework.dll        // and fail to find them    });

Separate Schemas

Isolate configuration and operational tables using DefaultSchema to avoid naming collisions and simplify backup strategies:

csharp
// ✅ Recommended for production: dedicated schemas per storebuilder.Services.AddIdentityServer()    .AddConfigurationStore(options =>    {        options.DefaultSchema = "idscfg";        options.ConfigureDbContext = b =>            b.UseSqlServer(connectionString, sql =>            {                sql.MigrationsAssembly(migrationsAssembly);                sql.MigrationsHistoryTable("__ConfigMigrationsHistory", "idscfg");            });    })    .AddOperationalStore(options =>    {        options.DefaultSchema = "idsop";        options.ConfigureDbContext = b =>            b.UseSqlServer(connectionString, sql =>            {                sql.MigrationsAssembly(migrationsAssembly);                sql.MigrationsHistoryTable("__OperationalMigrationsHistory", "idsop");            });    });

Migrations

EF Core migrations must be created in the host assembly. IdentityServer does not generate or apply migrations automatically.

Creating Migrations

shell
# Configuration store migrationdotnet ef migrations add InitialIdentityServerConfigurationDb \  --context ConfigurationDbContext \  --output-dir Data/Migrations/IdentityServer/ConfigurationDb
# Operational store migrationdotnet ef migrations add InitialIdentityServerOperationalDb \  --context PersistedGrantDbContext \  --output-dir Data/Migrations/IdentityServer/OperationalDb

Applying Migrations at Startup

csharp
// ✅ Apply EF migrations on startup (suitable for dev/staging; use a deploy pipeline in production)public static void InitializeDatabase(IApplicationBuilder app){    using var serviceScope = app.ApplicationServices        .GetRequiredService<IServiceScopeFactory>()        .CreateScope();
    serviceScope.ServiceProvider        .GetRequiredService<PersistedGrantDbContext>()        .Database        .Migrate();
    var configContext = serviceScope.ServiceProvider        .GetRequiredService<ConfigurationDbContext>();    configContext.Database.Migrate();
    // Seed initial configuration data if empty    if (!configContext.Clients.Any())    {        foreach (var client in Config.Clients)            configContext.Clients.Add(client.ToEntity());        configContext.SaveChanges();    }}

Handling Schema Updates Across Versions

When upgrading IdentityServer, always check the upgrade guide for schema changes before applying the new package version:

  1. Review the changelog for any new columns or tables in ConfigurationDbContext or PersistedGrantDbContext.
  2. Scaffold a new EF migration: dotnet ef migrations add UpgradeToV7x --context ConfigurationDbContext.
  3. Review the generated migration SQL — especially for columns with NOT NULL constraints that require backfill.
  4. Apply to a staging environment and validate before production.
csharp
// ❌ Never auto-apply migrations in production startup without a health gate// This causes downtime on multi-instance deployments where one instance// applies the migration while others still run against the old schemaapp.ApplicationServices.GetRequiredService<ConfigurationDbContext>()    .Database.Migrate(); // Dangerous in multi-node deployments

Caching Configuration Data

Configuration data (clients, resources, CORS) is read on every token request. Without caching, every request hits the database.

EF Store Caching (Recommended)

csharp
// ✅ Enable cache for the EF configuration store (v8: uses HybridCache)builder.Services.AddIdentityServer()    .AddConfigurationStore(options => { /* ... */ })    .AddConfigurationStoreCache(); // wraps EF stores with HybridCache

AddConfigurationStoreCache() wraps each configuration store with a caching decorator backed by Microsoft HybridCache. Cache expiration is controlled through IdentityServerOptions.Caching:

csharp
builder.Services.AddIdentityServer(options =>{    options.Caching.ClientStoreExpiration = TimeSpan.FromMinutes(5);    options.Caching.ResourceStoreExpiration = TimeSpan.FromMinutes(5);    options.Caching.CorsExpiration = TimeSpan.FromMinutes(5);    options.Caching.IdentityProviderCacheDuration = TimeSpan.FromMinutes(60);})    .AddConfigurationStore(options => { /* ... */ })    .AddConfigurationStoreCache();

Custom Store Caching

When using a custom IClientStore, wrap it with the caching decorator explicitly:

csharp
// ✅ Cache applied to a custom store implementationbuilder.Services.AddIdentityServer()    .AddClientStore<MongoClientStore>()    .AddResourceStore<MongoResourceStore>()    .AddClientStoreCache<MongoClientStore>()    .AddResourceStoreCache<MongoResourceStore>();

Distributed Cache for Multi-Node Deployments

In-memory cache is node-local — a client update only invalidates the cache on the node where the change was made. For multi-node deployments, configure HybridCache with a distributed backend:

csharp
// ✅ Configure HybridCache with Redis backend for multi-node scenariosbuilder.Services.AddHybridCache();builder.Services.AddStackExchangeRedisCache(options =>    options.Configuration = builder.Configuration["Redis:ConnectionString"]);
builder.Services.AddIdentityServer()    .AddConfigurationStore(options => { /* ... */ })    .AddConfigurationStoreCache();

Note: In v8, ICache<T> is replaced by Microsoft HybridCache. If you have custom ICache<T> implementations, migrate to HybridCache with keyed services (ServiceProviderKeys.ConfigurationStoreCache). See the identityserver-upgrade-v7-to-v8 skill for migration patterns.

After a client or resource update, explicitly evict the cache entry or wait for expiration. There is no built-in cache invalidation webhook.


Custom Stores

Implement custom stores when EF Core is unsuitable — for example, when client definitions live in an external system, or when operational data must be stored in Redis or a document database.

In-Memory Stores (Development Only)

For development and testing, in-memory stores avoid database setup entirely:

csharp
// ✅ In-memory stores — development and testing onlybuilder.Services.AddIdentityServer()    .AddInMemoryClients(Config.Clients)    .AddInMemoryApiScopes(Config.ApiScopes)    .AddInMemoryApiResources(Config.ApiResources)    .AddInMemoryIdentityResources(Config.IdentityResources);

In-memory stores are created once at startup and cannot be updated at runtime without restarting the application. They do not survive restarts and should never be used for operational data in production.

Version Note — CancellationToken parameters (v8+): The store interface signatures below include CancellationToken parameters, which were added in Duende IdentityServer v8. In v7 and earlier, these interfaces do not accept CancellationToken — omit the parameter when targeting v7. Additionally, IClientStore.GetAllClientsAsync is a new method in v8; it does not exist in v7.

IClientStore

csharp
// ✅ Custom client store reading from an external APIpublic sealed class ExternalApiClientStore : IClientStore{    private readonly IExternalClientApi _api;
    public ExternalApiClientStore(IExternalClientApi api)        => _api = api;
    public async Task<Client?> FindClientByIdAsync(string clientId, CancellationToken ct = default)    {        var dto = await _api.GetClientAsync(clientId);        return dto is null ? null : dto.ToIdentityServerClient();    }
    public async IAsyncEnumerable<Client> GetAllClientsAsync(CancellationToken ct = default)    {        await foreach (var dto in _api.GetAllClientsAsync(ct))            yield return dto.ToIdentityServerClient();    }}
csharp
// ✅ Registration — use helper method, not AddTransient directlybuilder.Services.AddIdentityServer()    .AddClientStore<ExternalApiClientStore>();

IResourceStore

csharp
// ✅ Custom resource store — must implement all five query methodspublic sealed class DatabaseResourceStore : IResourceStore{    private readonly ResourceRepository _repo;
    public DatabaseResourceStore(ResourceRepository repo) => _repo = repo;
    public Task<IEnumerable<IdentityResource>> FindIdentityResourcesByScopeNameAsync(        IEnumerable<string> scopeNames, CancellationToken ct = default)        => _repo.GetIdentityResourcesAsync(scopeNames);
    public Task<IEnumerable<ApiScope>> FindApiScopesByNameAsync(        IEnumerable<string> scopeNames, CancellationToken ct = default)        => _repo.GetApiScopesAsync(scopeNames);
    public Task<IEnumerable<ApiResource>> FindApiResourcesByScopeNameAsync(        IEnumerable<string> scopeNames, CancellationToken ct = default)        => _repo.GetApiResourcesByScopeAsync(scopeNames);
    public Task<IEnumerable<ApiResource>> FindApiResourcesByNameAsync(        IEnumerable<string> apiResourceNames, CancellationToken ct = default)        => _repo.GetApiResourcesByNameAsync(apiResourceNames);
    public Task<Resources> GetAllResourcesAsync(CancellationToken ct = default)        => _repo.GetAllAsync();}

IPersistedGrantStore

csharp
// ✅ Custom persisted grant store — all methods must be implementedpublic sealed class RedisPersistedGrantStore : IPersistedGrantStore{    private readonly IDatabase _redis;
    public RedisPersistedGrantStore(IConnectionMultiplexer mux)        => _redis = mux.GetDatabase();
    public async Task StoreAsync(PersistedGrant grant, CancellationToken ct = default)    {        var json = JsonSerializer.Serialize(grant);        var expiry = grant.Expiration.HasValue            ? grant.Expiration.Value - DateTimeOffset.UtcNow            : TimeSpan.FromDays(30);        await _redis.StringSetAsync(grant.Key, json, expiry);    }
    public async Task<PersistedGrant?> GetAsync(string key, CancellationToken ct = default)    {        var value = await _redis.StringGetAsync(key);        return value.IsNull ? null : JsonSerializer.Deserialize<PersistedGrant>(value!);    }
    public async Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter, CancellationToken ct = default)    {        // Redis requires a secondary index (e.g., SET per subjectId) for filtered queries        // Implementation depends on your indexing strategy        throw new NotImplementedException("Implement with a subject-keyed index");    }
    public Task RemoveAsync(string key, CancellationToken ct = default)        => _redis.KeyDeleteAsync(key);
    public Task RemoveAllAsync(PersistedGrantFilter filter, CancellationToken ct = default)    {        // Requires secondary index lookup        throw new NotImplementedException("Implement with a subject-keyed index");    }}
csharp
// ✅ Registration for custom operational stores — register directly, not through builder helpersbuilder.Services.AddIdentityServer();builder.Services.AddTransient<IPersistedGrantStore, RedisPersistedGrantStore>();builder.Services.AddTransient<IDeviceFlowStore, YourCustomDeviceFlowStore>();

Server-Side Sessions Store

Server-side sessions (added in IdentityServer 6.1) keep authentication session data server-side rather than in the cookie, enabling centralized session management, inactivity timeouts, and back-channel logout across all sessions for a user.

Enabling with EF Core

csharp
// ✅ Server-side sessions backed by the EF operational storebuilder.Services.AddIdentityServer()    .AddServerSideSessions()     // must be called to enable the feature    .AddOperationalStore(options =>    {        options.ConfigureDbContext = b =>            b.UseSqlServer(connectionString, sql =>                sql.MigrationsAssembly(migrationsAssembly));        options.EnableTokenCleanup = true;    });

Custom IServerSideSessionStore

csharp
// ✅ Custom server-side session storebuilder.Services.AddIdentityServer()    .AddServerSideSessions<YourCustomSessionStore>();
// Equivalent to:builder.Services.AddIdentityServer()    .AddServerSideSessions()    .AddServerSideSessionStore<YourCustomSessionStore>();

The IServerSideSessionStore interface provides methods for CreateSessionAsync, GetSessionAsync, UpdateSessionAsync, DeleteSessionAsync, and bulk query/management methods used by session expiration and back-channel logout coordination. All methods must be implemented — there are no default no-op implementations.

Session Cleanup

Session records accumulate over time. Token cleanup (EnableTokenCleanup) removes expired sessions from the EF operational store. For custom stores, you must implement your own cleanup background service.


Signing Key Store

Duende IdentityServer's automatic key management feature dynamically creates and rotates signing keys. Keys must be persisted across restarts and shared across nodes.

Default: File System

The default ISigningKeyStore persists keys to the file system. This is suitable for single-node deployments only:

csharp
// ✅ File system key store (default) — single node onlybuilder.Services.AddIdentityServer()    .AddDeveloperSigningCredential(); // development only
// For production single-node: nothing extra needed; file system is the default

EF Core Key Store

AddOperationalStore() automatically registers ISigningKeyStore against PersistedGrantDbContext:

csharp
// ✅ EF-backed signing key store — required for multi-node deploymentsbuilder.Services.AddIdentityServer()    .AddOperationalStore(options =>    {        options.ConfigureDbContext = b =>            b.UseSqlServer(connectionString, sql =>                sql.MigrationsAssembly(migrationsAssembly));    });// ISigningKeyStore is now backed by PersistedGrantDbContext

Custom ISigningKeyStore

csharp
// ✅ Register a custom signing key storebuilder.Services.AddIdentityServer()    .AddSigningKeyStore<YourCustomSigningKeyStore>();

The ISigningKeyStore interface has three methods (CancellationToken parameters are v8+ only — see version note above):

  • LoadKeysAsync(CancellationToken ct) — returns all SerializedKey records; called on startup and periodically
  • StoreKeyAsync(SerializedKey key, CancellationToken ct) — persists a newly created key
  • DeleteKeyAsync(string id, CancellationToken ct) — removes a retired key

Data Protection Considerations

The Data property of SerializedKey may be encrypted via ASP.NET Core Data Protection (check DataProtected == true). When implementing a custom store:

  • Do not re-encrypt data returned from LoadKeysAsync — IdentityServer decrypts it internally.
  • Ensure Data Protection keys are shared across all nodes in a multi-node deployment. If node A encrypts a signing key and node B cannot decrypt it, token signing will fail.
  • Store Data Protection keys in a shared location (Azure Blob, SQL, Redis) and protect them with a shared certificate or key vault key.
csharp
// ✅ Share Data Protection keys across nodes (Azure Blob + Key Vault example)builder.Services.AddDataProtection()    .PersistKeysToAzureBlobStorage(/* blob container */)    .ProtectKeysWithAzureKeyVault(/* key vault key id */);

Token Cleanup

Operational data accumulates continuously. Without cleanup, the PersistedGrants table grows unbounded, degrading query performance.

Enabling Automatic Cleanup

csharp
// ✅ Enable token cleanup in the EF operational storebuilder.Services.AddIdentityServer()    .AddOperationalStore(options =>    {        options.ConfigureDbContext = b =>            b.UseSqlServer(connectionString, sql =>                sql.MigrationsAssembly(migrationsAssembly));
        options.EnableTokenCleanup = true;        options.TokenCleanupInterval = 3600;      // seconds; default 1 hour
        // Remove consumed one-time tokens (e.g., used refresh tokens with OneTime usage)        options.RemoveConsumedTokens = true;        options.ConsumedTokenCleanupDelay = 0;    // seconds to wait before deleting consumed tokens
        // Fuzz startup time to reduce multi-node cleanup conflicts (default: true)        options.FuzzTokenCleanupStart = true;    });

OperationalStoreOptions Reference

OptionTypeDefaultDescription
ConfigureDbContextAction<DbContextOptionsBuilder>—Configure the PersistedGrantDbContext
DefaultSchemastring—Default database schema for operational tables
EnableTokenCleanupboolfalseEnable automatic cleanup of expired grants and pushed authorization requests
RemoveConsumedTokensboolfalseAlso remove consumed grants during cleanup (added >= 5.1)
TokenCleanupIntervalint3600Cleanup interval in seconds
TokenCleanupBatchSizeint100Number of expired tokens removed per cleanup cycle
ConsumedTokenCleanupDelayint0Seconds to wait after consumption before cleaning up (added >= 6.3)
FuzzTokenCleanupStartbooltrueRandomize first cleanup run to avoid multi-instance conflicts (added >= 7.0)

What Token Cleanup Removes

The TokenCleanupService removes:

  • Persisted grants where Expiration < UtcNow
  • Consumed tokens when RemoveConsumedTokens = true and ConsumedTime + ConsumedTokenCleanupDelay < UtcNow
  • Expired device flow codes
  • Expired pushed authorization requests
  • Expired server-side sessions

It does not remove:

  • Active (non-expired) refresh tokens that have been marked consumed — these are retained for threat detection unless RemoveConsumedTokens = true

Grant Lifecycle States

StateMeaning
Record exists, no ConsumedTime, within ExpirationGrant is valid
ConsumedTime is setGrant has been used (soft delete)
Past ExpirationGrant is expired
Record deletedGrant is revoked

One-time-use grants (authorization codes, optionally refresh tokens) use the consumption mechanism instead of immediate deletion to enable replay detection and grace periods. The Data property of persisted grants is the authoritative payload — other properties like Created and Expiration are read-only indices. Modifying index properties directly in the database will not change runtime behavior.

Multi-Node Cleanup Conflicts

When multiple nodes all run cleanup at the same interval, they race to delete the same rows. FuzzTokenCleanupStart = true (the default) randomises the first cleanup run within the interval window. For very high-scale deployments, consider disabling cleanup on all nodes and running it as a dedicated background job:

csharp
// ✅ Disable cleanup on web nodes; run in a dedicated worker service// In web node Program.cs:options.EnableTokenCleanup = false;
// In a dedicated worker:public sealed class TokenCleanupWorker : BackgroundService{    private readonly TokenCleanupService _cleanup;
    public TokenCleanupWorker(TokenCleanupService cleanup) => _cleanup = cleanup;
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)    {        while (!stoppingToken.IsCancellationRequested)        {            await _cleanup.CleanupGrantsAsync();            await Task.Delay(TimeSpan.FromHours(1), stoppingToken);        }    }}

Persisted Grant Service

For higher-level programmatic access to grants (e.g., building an admin UI or user consent management page), use IPersistedGrantService rather than querying IPersistedGrantStore directly:

csharp
// ✅ Query and revoke grants via the high-level servicepublic sealed class GrantManagementService{    private readonly IPersistedGrantService _grantService;
    public GrantManagementService(IPersistedGrantService grantService)        => _grantService = grantService;
    public async Task<IEnumerable<Grant>> GetUserGrantsAsync(string subjectId)        => await _grantService.GetAllGrantsAsync(subjectId);
    public async Task RevokeClientGrantsAsync(string subjectId, string clientId)        => await _grantService.RemoveAllGrantsAsync(subjectId, clientId);}

This service abstracts and aggregates different grant types (authorization codes, refresh tokens, reference tokens, consent) into a unified API. It is the recommended way to implement user-facing grant/consent management rather than querying the low-level IPersistedGrantStore.


Multi-Tenant Patterns

Multi-tenant IdentityServer deployments require careful consideration of store boundaries.

Shared Database (Recommended for Most Cases)

A single ConfigurationDbContext and PersistedGrantDbContext shared across all tenants. Tenant isolation is enforced at the application layer by scoping queries to a TenantId column.

csharp
// ✅ Shared database with tenant-scoped custom storespublic sealed class TenantAwareClientStore : IClientStore{    private readonly AppDbContext _db;    private readonly ITenantContext _tenantContext;
    public TenantAwareClientStore(AppDbContext db, ITenantContext tenantContext)    {        _db = db;        _tenantContext = tenantContext;    }
    public async Task<Client?> FindClientByIdAsync(string clientId)    {        var entity = await _db.Clients            .Where(c => c.TenantId == _tenantContext.TenantId && c.ClientId == clientId)            .FirstOrDefaultAsync();        return entity?.ToIdentityServerClient();    }}

Database-per-Tenant

Each tenant gets its own connection string and EF DbContext instance. This provides the strongest data isolation, is appropriate for compliance requirements (GDPR data residency, SOC2 segmentation), and simplifies tenant offboarding.

csharp
// ✅ Database-per-tenant using a factory pattern for the DbContextbuilder.Services.AddIdentityServer()    .AddClientStore<TenantRoutingClientStore>();
public sealed class TenantRoutingClientStore : IClientStore{    private readonly IDbContextFactory<ConfigurationDbContext> _factory;    private readonly ITenantConnectionStringProvider _connectionStrings;    private readonly ITenantContext _tenantContext;
    public TenantRoutingClientStore(        IDbContextFactory<ConfigurationDbContext> factory,        ITenantConnectionStringProvider connectionStrings,        ITenantContext tenantContext)    {        _factory = factory;        _connectionStrings = connectionStrings;        _tenantContext = tenantContext;    }
    public async Task<Client?> FindClientByIdAsync(string clientId)    {        var connStr = await _connectionStrings.GetAsync(_tenantContext.TenantId);        var options = new DbContextOptionsBuilder<ConfigurationDbContext>()            .UseSqlServer(connStr)            .Options;
        await using var ctx = new ConfigurationDbContext(options, new ConfigurationStoreOptions());        var entity = await ctx.Clients            .Include(c => c.AllowedScopes)            .Include(c => c.RedirectUris)            .FirstOrDefaultAsync(c => c.ClientId == clientId);
        return entity?.ToModel();    }}
csharp
// ❌ Avoid: sharing PersistedGrantDbContext across tenants without tenant isolation// A token issued for tenant A can be looked up by tenant B's store — a security boundary violationbuilder.Services.AddOperationalStore(options =>{    options.ConfigureDbContext = b => b.UseSqlServer(sharedConnectionString);    // No tenant filtering applied — all tenants share the same grant store});

Store Implementation Decision Matrix

ScenarioRecommendation
Prototyping / local developmentIn-memory stores (AddInMemory*)
Small deployment, rare config changesIn-memory stores loaded from config files
Production with relational databaseEF Core stores with AddConfigurationStoreCache()
High-traffic productionEF Core stores + caching + tuned cleanup intervals
Non-relational database (Redis, Cosmos, etc.)Custom store implementations
SaaS with dynamic configurationEF Core or custom stores with API for management

Common Pitfalls

Missing MigrationsAssembly — The most common EF setup error. When migrations live in the host project (not in Duende.IdentityServer.EntityFramework), you must call sql.MigrationsAssembly(migrationsAssembly). Without this, dotnet ef migrations add and runtime startup fail.

Calling AddConfigurationStoreCache() without AddInMemoryCaching() — AddConfigurationStoreCache() wraps the EF stores automatically and includes its own IMemoryCache registration. AddInMemoryCaching() is needed when you are manually registering caching decorators on custom stores with AddClientStoreCache<T>().

In-memory caching in multi-node deployments — The default IMemoryCache-backed cache is node-local. If you update a client configuration and one node caches the old value, token requests on that node will use the stale configuration until the cache expires. Use a distributed cache (IDistributedCache) to share cache state, or set a short expiration and accept eventual consistency.

Not enabling server-side sessions before the operational store — AddServerSideSessions() must be called before or alongside AddOperationalStore(). Reversing the order or omitting AddServerSideSessions() means session data is never persisted, and session management features silently degrade.

Assuming EnableTokenCleanup = true removes consumed tokens — By default, consumed tokens are not cleaned up. You must also set RemoveConsumedTokens = true. Consumed tokens from one-time-use refresh token flows will otherwise accumulate indefinitely.

Rotating Data Protection keys without migrating encrypted grant data — Signing keys and grant payloads encrypted with an old Data Protection key become unreadable after key rotation. Always keep retired Data Protection keys available for decryption for at least as long as the longest-lived grant (typically refresh token lifetime).

Running EF migrations in multi-instance startup — Calling Database.Migrate() in Program.cs on every startup causes migration races in multi-node deployments. Run migrations as a deployment pre-step (e.g., a Kubernetes init container or a CI/CD migration job), not in the application startup path.

Using in-memory stores in production — AddInMemoryClients(), AddInMemoryApiResources(), etc. are designed for development and testing only. In-memory stores cannot be updated at runtime without restarting the application and do not survive restarts.


Resources

來源與署名

來源:DuendeSoftware/duende-skills位於skills/identityserver-stores提交fb32edc

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架