Sessions
A signed-in user's session here is an ASP.NET Core cookie scheme: where it is, how long it lasts, how a page of the
host's own signs a user in to it, and how it becomes something the server can list and end. The code on this page is
included from samples/Server.State,
which a test boots, signs a user in to, and ends the session of. Every setting below, with its default, is under
AuthenticationOptions.
The session cookie
// Duende's defaults are ten hours, not sliding, SameSite=None. These are what this server hard-coded before
// 0.101.0, for a host that relied on them.
options.Authentication.CookieLifetime = TimeSpan.FromHours(8);
options.Authentication.CookieSlidingExpiration = true;
options.Authentication.CookieSameSiteMode = SameSiteMode.Lax;
Since 0.101.0 the session cookie's lifetime, sliding and SameSite mode are on IdentityServerOptions.Authentication,
at Duende's defaults — ten hours, not sliding, and SameSite=None, which also applies to the external-sign-in
cookie. Until then this server hard-coded eight hours, sliding and Lax, so a host upgrading that relied on a session
sliding has to say so, as the sample does.
With sliding on and server-side sessions registered, a client with
CoordinateLifetimeWithUserSession keeps the browser's session alive too: when its refresh or access token is used, the
stored ticket is renewed with the session — if it is persistent and may be refreshed — and the browser's cookie is
reissued on its next request. With sliding off only the stored session slides.
The options are applied as configuration of the cookie scheme, so the scheme's own options, configured after
AddIdentityServer(), still have the last word — for these and for the cookie's name, which stays
CookieAuthenticationOptions':
// The cookie scheme's own options, configured after AddIdentityServer(), have the last word — and the cookie's name
// is theirs.
builder.Services.Configure<CookieAuthenticationOptions>(
IdentityServerConstants.DefaultCookieAuthenticationScheme,
options => options.Cookie.Name = "acme.auth");
Which scheme holds the session
Since 0.107.0, resolved as Duende resolves it: AuthenticationOptions.CookieAuthenticationScheme when set, else the
host's default authenticate scheme — and, where Duende would refuse for want of either, idsrv when it is registered. A
host that keeps the session in a cookie of its own, or puts a policy scheme in front, names it:
/// <summary>
/// The session kept in a cookie scheme of the host's own. Everything that reads or writes the session goes there,
/// so it must be a scheme that can sign in; one that is not registered is refused at startup.
/// </summary>
public static IServiceCollection UseHostSessionScheme(this IServiceCollection services)
{
services.AddAuthentication().AddCookie("host-session");
services.AddIdentityServer(options => options.Authentication.CookieAuthenticationScheme = "host-session");
return services;
}
Everything that reads or writes the session goes there — the sign-in and external sign-in pages, the session id and
client list, sign-out, the authorize and SAML sign-on endpoints, and server-side sessions' ticket store — and a scheme
named here that is not registered is refused at startup. A sign-in page of a host's own signs in to
await HttpContext.GetCookieAuthenticationSchemeAsync(). Until 0.107.0 idsrv was hard-coded at most of those sites and
a few consulted the default scheme first, so a host whose default was not idsrv had a session split between two
cookies. The session scheme is written to, as in Duende, so it has to be one that can sign in.
Signing a user in from a page of your own
// A sign-in of the host's own. IdentityServerUser makes the principal the server expects, and SignInAsync puts it
// in the session's scheme, where it is completed as any sign-in is: idp=local, amr=pwd, auth_time now.
app.MapPost("/account/signin", async (SignInRequest request, TestUserStore users, HttpContext http) =>
{
if (users.ValidateCredentials(request.Username, request.Password) is not { } user)
{
return Results.Unauthorized();
}
await http.SignInAsync(new IdentityServerUser(user.SubjectId) { DisplayName = user.Username });
return Results.NoContent();
});
Since 0.108.0 a host's own sign-in signs a user in the way a Duende host's does.
IdentityServerUser makes Duende's principal — DisplayName, and IdentityProvider,
Tenant, AuthenticationMethods, AuthenticationTime and AdditionalClaims as needed — and SignInAsync puts it in
the session's scheme. Any sign-in to that scheme — this, the shipped pages, or a host's own
HttpContext.SignInAsync(principal) — is checked and completed as Duende completes it: one identity with a sub, or it
is refused; a missing idp is local, a missing amr is pwd for a local sign-in and external for any other, and a
missing auth_time is now. So since 0.108.0 a local sign-in's tokens carry idp=local and amr=pwd, and an external
sign-in's an auth_time it never had.
Since 0.174.0 that sign-in also gives the session its id, as Duende's authentication service does
(DefaultUserSession.CreateSessionIdAsync, measured): the cookie is written with a session id unless the user carries a
sid claim of the host's own, which is used instead. Until then the id was created the first time something asked for
it, which re-issued the cookie — so the first authorization after a sign-in, which also records its client, set the
session cookie twice (the authorization code flow recorded it). Now it sets it once,
and a server-side session's row has its id from the sign-in. A session signed in before the
upgrade is still given an id on first use. Each sign-in gets a new id, as before; Duende's keeps the session's id and
client list when the same user signs in again.
Sign-out is the same in reverse: any sign-out of the session's scheme, a host's own HttpContext.SignOutAsync()
included, runs this server's sign-out — the event, the check-session state, coordinated revocation and back-channel
logout.
The check-session cookie
// A name of this server's own, so its check-session iframe never reads another server's browser state on a
// shared domain; and no domain, so the cookie stays host-only.
options.Authentication.CheckSessionCookieName = "acme.session";
options.Authentication.CheckSessionCookieDomain = null;
SessionStateService writes the browser state to a cookie the check-session iframe reads, when the authorize endpoint
answers a signed-in user; its name and domain are settable. The name matters when two identity servers share a
registrable domain: both wrote idsrv.session, and each one's iframe then read the other's browser state — a relying
party told that its session changed by a server it has nothing to do with, or told nothing by one that ended. The domain
is null unless a host sets it, which leaves the cookie host-only; widening it to a registrable domain is the decision that
causes the collision in the first place, so it has to be written down.
Check-session does not work over plain HTTP. The cookie is read from a third-party context — the iframe runs inside
the relying party's page — so it is written SameSite=None, which browsers honour only with Secure. There is no option
to relax that, and there will not be: the escape from an HTTP development setup would be an insecure cookie, not a
setting.
Server-side sessions
By default the authentication ticket travels in the cookie, which is why a session can be identified and not found: nothing lists the sessions a subject has, and nothing ends one from outside the browser holding it. AddServerSideSessions keeps the ticket on the server and puts a key in the cookie instead, so a session becomes something the server can enumerate and revoke.
builder.Services.AddIdentityServerCookieAuthentication();
// After AddIdentityServerCookieAuthentication: this configures the scheme that call registers, and configuring a
// scheme that does not exist yet does nothing. The ticket is kept on the server and the cookie carries its key.
builder.Services.AddServerSideSessions();
The store behind it defaults to the in-memory one, which is for development. In production it has to be the Dapper
store, and that means keeping DapperStores.ServerSideSessions in the selection — it is in DapperStores.All, so a host
that names no stores already has it:
/// <summary>
/// Server-side sessions in the database: without ServerSideSessions in the selection they are held per process,
/// vanish on deploy, and sign a user out whenever the load balancer moves them.
/// </summary>
public static IIdentityServerBuilder AddSharedSessionStore(this IIdentityServerBuilder identityServer, string connectionString)
=> identityServer.AddDapperStores(options =>
{
options.Provider = DatabaseProvider.SqlServer;
options.ConnectionString = connectionString;
options.Stores = DapperStores.PersistedGrants | DapperStores.SigningKeys | DapperStores.ServerSideSessions;
});
Leaving it out is the one omission here that behaves perfectly in every test and fails only in production: sessions are then held per process, so they vanish on deploy and a user appears signed out whenever the load balancer moves them. Persistence covers the Dapper stores themselves.
AddServerSideSessions() also registers the sweep that removes expired sessions, on an interval, and hands each removed
session a back-channel logout so the clients signed in to it find out. Without it a session row outlives the session
forever — a user who closes the browser signs out of nothing — and every relying party keeps believing a session that
ended hours ago is still live. A session has expired when its Expires is at or before the server's clock — the
TimeProvider registered, as every other expiry here is judged; until 0.175.0 the in-memory and Dapper session stores
read the machine's clock instead (both take a TimeProvider now, as Duende's in-memory store does).
Each session the sweep removes raises SessionExpiredEvent, and each session ended through
ISessionManagementService raises SessionRevokedEvent, so a host with a sink sees
the two ways a session ends that nobody clicked anything for. Both need RaiseInformationEvents on, like every other
event here.
The sweep runs outside any request, which has one consequence worth configuring for: there is no incoming URL to derive
an issuer from, so its logout tokens need IssuerUri set. Without it the sessions are still removed and their grants
revoked, and a warning says the clients were not told.
// The expired-session sweep runs outside any request, so there is no URL to take an issuer from: without
// this its sessions are still removed, but the clients signed in to them are not sent a logout token.
options.IssuerUri = "https://sts.example.com";
Ending sessions from an administration page
/// <summary>
/// Ends a user's sessions for one client: the client's tokens are revoked and it is sent a back-channel logout,
/// but the consent the user gave it is kept, so signing in again asks nothing.
/// </summary>
public Task EndSessionsForClientAsync(string subjectId, string clientId, CancellationToken cancellationToken)
=> sessions.RemoveSessionsAsync(
new RemoveSessionsContext
{
SubjectId = subjectId,
ClientIds = [clientId],
RevokeConsents = false,
},
cancellationToken);
New in 0.162.0, found by the final re-measure (row 38 of docs/surface-remeasure.md), measured from Duende 8.0.1's
IL. ISessionManagementService.RemoveSessionsAsync(RemoveSessionsContext) is Duende's: a SubjectId and SessionId,
the ClientIds to act on (null for all), and four flags, RemoveServerSideSession, SendBackchannelLogoutNotification,
RevokeTokens and RevokeConsents, each true unless turned off. It is done as Duende's default does it, in its order:
first the grants — the subject's (and the session's, when one is named) for the named clients, every type when both
revocations are asked, user_consent alone, or refresh tokens, reference tokens and authorization codes alone; then each
live session's clients — the ones its ticket recorded, narrowed to the named ones — told it was terminated; then the
rows. An empty ClientIds narrows no revocation and tells no client, as Duende's does. The filter overload,
RemoveSessionsAsync(SessionFilter), does what it did: removes the rows first, tells the clients the session's grants
name, revokes those grants and leaves consents. QuerySessionsAsync pages through the sessions for a list.
Not breaking: a default interface method, so a host's service written before keeps compiling; asked it, one that
does not implement it throws NotSupportedException (Duende's interface has no default).
DefaultSessionManagementService gains a constructor taking the ticket store, the grant store and the back-channel
logout service as well, which the container chooses; one built with the old constructor refuses a context with
InvalidOperationException.
Ours: a context naming neither a subject nor a session is ArgumentException before anything is touched, where
Duende's first revokes what its grant filter alone matches — every user's tokens — and then throws; Duende's fourth token
type, ciba, is not revoked, this server keeping CIBA requests in their own store rather than among the grants; the
grants go one client and one type at a time, this server's grant filter carrying one of each; and the filter overload's
events are raised — SessionRevokedEvent per row removed, GrantsRevokedEvent per client whose grants went when a
subject is named — where Duende's raises none.
A session that expires or is ended this way also ends the tokens issued in it — see tokens that end with the session.