What the server keeps, and where
Between requests the server holds configuration it reads, grants it issued, codes it is waiting on, credentials it has
already seen and messages for its pages. Persistence puts the stores in a database; this page is
what is in them, what is cached in front of them, and what has to be shared once there is more than one instance. The
code is included from samples/Server.State.
Caching the configuration store
/// <summary>
/// Clients and resources read from SQL Server, and held for a while rather than read on every protocol request.
/// </summary>
public static IIdentityServerBuilder AddCachedConfigurationStore(this IIdentityServerBuilder identityServer, string connectionString)
=> identityServer
.AddDapperStores(options =>
{
options.Provider = DatabaseProvider.SqlServer;
options.ConnectionString = connectionString;
})
.AddClientStoreCache<DapperClientStore>() // 15 minutes, as Duende's
.AddResourceStoreCache<DapperResourceStore>(TimeSpan.FromMinutes(5));
New in 0.140.0, Duende 8.0.1's, measured from its CachingClientStore<T>, CachingResourceStore<T> and the two
registrations. Every protocol request reads its client and its scopes, so a store that is a database is asked far more
often than its rows change. Name the store to cache it — after AddDapperStores, or in place of AddClientStore<T> /
AddResourceStore<T> for your own — with
AddClientStoreCache and
AddResourceStoreCache:
- A client is held by its id, behind the validating store, so what is held has been
validated. An unknown or refused client is not held: a client added to the store works on
its first request, and a refused one is refused, logged and raised as
InvalidClientConfigurationEventeach time it is asked for, as without the cache. - The resources are held as one entry — the store's
GetAllResourcesAsync— and every lookup is answered from it, an API resource by any of its scopes. A store that answers null is not held, and reads as no resources. - An edit reaches the server within the duration, not at once — a disabled client and a removed secret included. There is no duration that turns a cache off; a host that does not want one does not register it.
Both use the HybridCache under the key DynamicProviderExtensions.ConfigurationStoreCacheKey, the one the
identity-provider cache uses: out of the box in memory and private to each process. A host that registers Microsoft's
under that key gets a distributed second level for all three:
/// <summary>
/// Microsoft's HybridCache under the configuration stores' key: a distributed second level, shared by every
/// instance, for the client, resource and identity-provider caches.
/// </summary>
public static IServiceCollection AddSharedConfigurationCache(this IServiceCollection services)
{
services.AddKeyedHybridCache(DynamicProviderExtensions.ConfigurationStoreCacheKey);
return services;
}
Microsoft's holds a client serialized; every member a request reads comes back. Ours: the duration is an argument
where Duende reads CachingOptions.ClientStoreExpiration and ResourceStoreExpiration — this server has no Caching
group, as recorded at 0.49.0 — and Duende's AddInMemoryCaching() is not here: the registrations add the in-memory cache
themselves when there is none.
IClientStore.GetAllClientsAsync— breaking in 0.140.0, Duende 8.0.1's. Every client in the store, as anIAsyncEnumerable<Client>, for a host's own enumeration — an administration page, an audit. The in-memory store yields its clients in the order given, the Dapper store by client id, the validating store each one it does not refuse (raising the event for one it does), and the cache passes it to the store uncached, as Duende's does. A host's ownIClientStoremust now implement it. Nothing on a request path here enumerates clients, as nothing in Duende does; its one other reader, Duende'sIConnectedApplicationStore, arrived with the SAML service provider model in 0.143.0.
What is in the grant table
A persisted grant's Data is the serialized grant itself. For a refresh token, a reference token or an authorization
code that is the whole claim set the subject was issued — every claim a profile service added included — so the table
holds, for as long as those grants live, the identity of everybody with a session. It is protected with the host's data
protection ring before it is stored, and that is on by default (PersistentGrants.DataProtectData). Turning it off:
/// <summary>
/// Persisted grants written without data protection — for a store encrypted at rest by other means, or one that
/// must be read with a query. Rows already written either way still read.
/// </summary>
public static IServiceCollection StoreGrantDataUnprotected(this IServiceCollection services)
=> services.Configure<IdentityServerOptions>(options => options.PersistentGrants.DataProtectData = false);
Turning it off does not make old rows unreadable, and neither does turning it on. The setting governs writes only. A read always tries to unprotect and falls back to treating the value as plaintext, on data protection's own format check rather than on a guess, so a table holding both shapes at once — the ordinary state during an upgrade, or after a host changes its mind — reads correctly either way. Nothing has to be migrated and no session ends at deploy.
Off is a real choice rather than a legacy setting. A host that keeps the store encrypted at rest by other means, or that needs to read the column with a query rather than through this server, is moving the protection rather than dropping it. What it must not do is assume the column is unreadable while this is off.
Two limits worth stating. Only Data is protected: Key is what a lookup is by, and SubjectId, ClientId,
SessionId, Type and the timestamps are what PersistedGrantFilter queries on, so protecting them would leave the
store unable to find its own rows. This hides the contents of a grant, not the fact that a subject has one. And the
ring is the host's, so grants written by one instance are readable by another only where the ring is shared — the same
requirement the signing keys and the antiforgery tokens already place on it.
This applies to whichever store is registered. The wrapper is re-applied by AddPersistedGrantStore<T>() and by
AddDapperStores, because both register over what AddIdentityServer() left behind, and a protection that survived only
the in-memory store would be absent from every deployment that keeps grants past a process lifetime. A store registered
by hand with services.Replace after all of that is the one case it does not reach.
What is kept between requests
New in 0.131.0, Duende 8.0.1's, measured.
Device codes and user codes are hashed before they are stored, as authorization codes, refresh tokens and reference tokens already were:
IDeviceFlowCodeService(Duende's) makes the device code and hands the store the hashes, and the device authorization endpoint, the token endpoint's device grant and the approval page all go through it. Until 0.131.0 a copy of the device flow table was a list of codes to poll the token endpoint with. An upgrade drops the device authorizations in flight — they were stored under the codes as issued — so a device polling at that moment is told its code is invalid and starts again; they last minutes, and nothing is migrated. The Dapper schema is unchanged: a hash is 43 characters and fits both columns.A grant filter must name something.
PersistedGrantFilter.Validate()(Duende's) throwsArgumentException, "No filter values set.", for a filter with no subject, session, client or type, and both stores call it. The Dapper store built itsWHEREclause from whichever fields were set, soRemoveAllAsyncwith none ranDELETE FROM PersistedGrants. Nothing in this server sent one; a host's own code calling a store directly now cannot either.The replay cache and the polling throttles live in
IDistributedCache.DefaultReplayCacheand the distributed device-flow and CIBA throttles (Duende's) are the defaults, andAddIdentityServerregisters a distributed memory cache for them, as Duende's does — so a single instance behaves as before. A host with several instances registers a real one, and every instance then shares what any has seen; nothing else changes:/// <summary> /// A real IDistributedCache for a server with several instances, so the replay cache and the device-flow and CIBA /// polling throttles are shared: a DPoP proof replayed to another instance is not new there. /// </summary> public static IServiceCollection AddSharedReplayCache(this IServiceCollection services, string redis) => services.AddStackExchangeRedisCache(options => options.Configuration = redis);Until 0.131.0 each instance kept its own, so a DPoP proof or a signed CIBA request replayed to a second instance was new there, and a device polling too fast escaped
slow_downby landing on another.InMemoryReplayCacheand the in-memory throttles are still there for a host that registered them. The throttle keys hold the codes' hashes, not the codes.A
private_key_jwtassertion is accepted once. It must carry ajti(OpenID Connect Core §9 requires one), and one whosejtithe replay cache has seen is refused, until five minutes past the assertion's expiry. Until 0.131.0 neither was checked, and an assertion captured from a request authenticated its client again until it expired. A client that sends nojtiis now refused — every client library that implementsprivate_key_jwtsends one.
The error page across instances
An authorize failure that cannot be returned to the client — an unknown client_id, an unregistered redirect_uri — is
not written as protocol JSON at the browser. The server stores the detail server-side as an ErrorMessage and redirects
to the error page carrying only an opaque errorId.
The default store is in-process (InMemoryMessageStore). Behind a load balancer the follow-up request for the error
page may land on a different instance, one that never saw that errorId. When the lookup misses, the page still
renders its generic message with the reference id displayed — and the same id is written to the logs, so support can
still find the matching log line. What degrades across instances is the on-page detail, not diagnosability.
To show detail on the page across instances, register your own IMessageStore<ErrorMessage> (it is an interface) with a
shared backing store. Rate-limit it first: a failed authorize request is unauthenticated, so a persisted-grant-backed
store would let an anonymous caller write a row per request, and there is no rate limiting in the server today. The
in-process default is deliberately kept as the safe out-of-the-box behaviour.
What it holds is bounded — since 0.177.0. The in-process store keeps at most
DefaultCapacity (10,000) messages, the oldest giving way, and
each write sweeps only the messages that have expired, oldest first; until then it kept every message for its whole
lifetime and each write walked all of them, so a flood of refused requests grew memory and made every write slower. The
authorize endpoint stores the requested client_id and redirect_uri only within InputLengths — a refusal for
exceeding them used to store the whole value, megabytes from one anonymous request — and an
ErrorMessage keeps neither past
MaxValueLength (2,048 characters), whoever writes it. The same store
carries consent decisions; a host whose instance takes more than 10,000 errors or decisions within a message's lifetime
registers the store with a larger capacity, or uses the protected store below:
/// <summary>
/// The in-process error store holding more than its default 10,000 messages, for an instance that refuses more
/// requests than that within a message's lifetime.
/// </summary>
public static IServiceCollection AddLargerErrorStore(this IServiceCollection services)
=> services.AddSingleton<IMessageStore<ErrorMessage>>(sp => new InMemoryMessageStore<ErrorMessage>(
sp.GetRequiredService<TimeProvider>(), TimeSpan.FromMinutes(5), capacity: 50_000));
Or carry the message in the id — new in 0.150.0, Duende's ProtectedDataMessageStore<T>:
// The error page's and the consent page's messages carried in their ids, protected with the data protection ring,
// instead of kept in this process: any instance sharing the ring reads what any other wrote.
.AddProtectedDataMessageStores();
AddProtectedDataMessageStores
replaces the error page's and the consent page's stores with one that keeps nothing. The message is serialized, protected
with the host's data protection ring and Base64Url-encoded as its own id, so any instance sharing the ring reads what any
other wrote, and an anonymous caller fills no table. Ours: it carries the message's expiry with it (each page's
lifetime from UserInteractionOptions), and a message that cannot be written throws where Duende's returns no id.
Opt-in, where Duende's is the default, because the error page shows the id, and this id is as long as the message.
The rejected redirect_uri behind that reference id is written to the logs, as a structured scope property rather
than in the log message text, so an attacker-supplied value cannot forge a log line. Microsoft.Extensions.Logging
drops scope state unless the provider opts in (IncludeScopes defaults to false), so a provider without scope
capture records everything about the failure except the redirect URI. Turn it on for whatever logging provider you
deploy — for the console:
// A rejected redirect_uri is logged as a scope property, never in the message text. Providers drop scopes unless
// they opt in.
builder.Services.Configure<SimpleConsoleFormatterOptions>(options => options.IncludeScopes = true);
The consent page's decision
New in 0.161.0, Duende 8.0.1's IConsentMessageStore, measured from its assembly; this closes the last 🟡 row of the
parity matrix. GrantConsentAsync and DenyAuthorizationAsync write the decision
through IConsentMessageStore as Duende's Message<ConsentResponse>, stamped with the
server's clock (Message<TModel> is Duende's too: Created in UTC ticks, Data), and the authorize endpoint reads it and
deletes it, so a host that registers its own by Duende's name —
services.AddSingleton<IConsentMessageStore, MyStore>(), before or after AddIdentityServer() — carries every decision;
AddIdentityServer() registers the default only where none is.
The default sits over what was there: the decision in IMessageStore<ConsentMessage> as the subject and client it always
recorded, that store's id in the consent cookie, so AddProtectedDataMessageStores() and a host's own
IMessageStore<ConsentMessage> keep working, and a decision in flight across the upgrade is still read. A message a store
hands back with no decision in it is answered server_error on the error page, Duende's "consent message is missing
data". Nothing breaks: the interaction service and the authorize endpoint take the store through new constructor
overloads, the old ones building the default over the message store they are given.
Ours: the id names the client and the subject — so a decision answers the next request that user makes of that
client, as this server's always has — where Duende's is a hash of them with the request's nonce and scopes
(ConsentRequest, not taken: it would narrow which request a decision answers); a store shared between instances
therefore holds one decision per user and client, and the endpoint reads and deletes on every signed-in request rather
than on the consent page's callback alone, deleting as soon as it reads. A refusal passed to GrantConsentAsync still is
not handed on — DenyAuthorizationAsync is what answers the client — where Duende's hands on any response.