Skip to main content

Stateless Users

By default, each login records the user in the ASP.NET Core Identity tables: AspNetUsers, their roles (AspNetUserRoles), their custom claims (AspNetUserClaims), their logins and external tokens (AspNetUserLogins, AspNetUserTokens). OpenIddict also records each authorization code, with the user's claims, in OpenIddictTokens.

An application that must not record any personal data can disable these storages. The user's identity (login, email, roles, claims) then lives only:

  • in the .{RootName}.Identity authentication cookie, encrypted by Data Protection;
  • in OpenIddict codes and tokens, self-contained and also encrypted by Data Protection, which are not recorded in the database.

On the authentication side, the only remaining write is an OpenIddict authorization per token renewal, which contains only a random identifier, the client, and scopes (see How it works).

Activation​

Main.cs
protected override void OnConfigureAuthentification(AuthenticationOptions options)
{
// No writes to Identity tables or OpenIddictTokens.
options.UserStorage = UserStorageMode.None;
}

The default value, UserStorageMode.Database, keeps the usual behavior.

Prerequisites​

  • SSO or Windows only. At startup, an InvalidOperationException is raised if AllowFormAuthentication is true, or if MultiFactorAuthenticationRequired or MultiFactorAuthenticationRoleName are set: form authentication and MFA require a stored user.
  • Roles given at each login. Roles cannot be read from the database: the application adds them in OnSignIn or OnSignInAsync with context.AddCustomRole(...). IsCustomRolesEnabled is forced to true and cannot be set back to false (InvalidOperationException). Without a role, login is refused (AuthNoAppAccess).
Main.cs
protected override async Task OnSignInAsync(AuthenticationContext context, ApplicationUser user)
{
// Display name, kept only in the cookie and tokens.
// A claim cannot have a null value: some providers do not provide a name.
string displayName = context.IdentitySource.Claims?.GetDisplayName();
if (!string.IsNullOrEmpty(displayName))
{
context.AddCustomClaim("DisplayName", displayName);
}
// Roles computed at each login (identity provider groups, configuration...).
context.AddCustomRole(Roles.User.ToString());
if (await IsAdministratorAsync(context))
{
context.AddCustomRole(Roles.Admin.ToString());
}
}

The DisplayName claim goes directly into both tokens. Another custom claim used by a server policy must be declared in AuthenticationOptions.AccessTokenClaims: see Policy based on a custom claim.

How it works​

StepDatabase modeNone mode
User lookupFindByEmailAsync / FindByNameAsyncNone. GetIdentityUserAsync and OnUnknownUserAsync are not called.
User passed to OnSignIn(Async)User from the databaseTransient user, never attached to DbContext: UserName = login, Email = login (email login) or email claim from the provider.
Roles, claims, external loginsStored in the databaseNot stored. Provider tokens remain in the cookie.
Identifier (sub)AspNetUsers.IdRandom Guid at each login: nothing links two sessions of the same person.
CookieSliding, security stamp re-read from the database every 30 minutesAbsolute expiration (SsoAuthenticationCookieLifeTime), no re-reading.
OpenIddict codes and tokensPrincipal reconstructed from the database, codes stored in OpenIddictTokensPrincipal copied from the cookie, no rows in OpenIddictTokens.
OpenIddict authorizationPermanentAdHoc (random identifier, client, scopes), deleted by the Quartz purge.
Refresh tokenIssued if the client requests offline_accessNever issued: the offline_access scope is removed.
Re-login (popup)Comparison of identifiers in the databaseThe provider's login must match the session cookie.

Consequences​

Session duration

Nothing re-reads the user during the session. A role change at the identity provider (group removal for example) only applies at the next login, or at cookie expiration at the latest: SsoAuthenticationCookieLifeTime, 1 day by default. This expiration is absolute: silent token renewals do not extend it. Reduce SsoAuthenticationCookieLifeTime or SessionLifetime (in minutes) if rights removal must be faster.

  • HttpContext.GetUserId() returns the session identifier, not that of a person. Modification traces (LgTrackedChanges.UserId) therefore carry this session identifier and the DisplayName claim.
  • Without storing codes and tokens, OpenIddict cannot detect authorization code reuse (the Lagoon Blazor client uses PKCE, which largely compensates for this) nor revoke a token: an access token remains valid until its expiration (AccessTokenLifetime, 30 minutes by default).
  • An application with OpenIddict consent External cannot use this mode: no permanent authorization corresponds to a random identifier.

Unavailable functions​

FunctionBehavior
CreateUserAsync, password reset and changeNotSupportedException.
MFARefused at startup (see MFA).
OAuth clients requesting offline_accessThe scope is removed: no refresh token. The Lagoon Blazor client renews its tokens without it.
Personal Remote grid profilesLgSettings.UserId has a foreign key to AspNetUsers: use Local mode (default, browser storage). Shared profiles work.
Notifications (Lagoon.UI.Notifications)NotificationUserWithUser.UserId has a foreign key to AspNetUsers: insertion fails.
Application entities linked to the userAny foreign key to the users table fails the same way.
Terms of Service (user acceptance)To be avoided: acceptance is linked to the session identifier. Terms are re-requested at each login and each acceptance writes a row to EulaAcceptances.

Identity tables remain in the data model: they are created by migrations but remain empty.