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}.Identityauthentication 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
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
InvalidOperationExceptionis raised ifAllowFormAuthenticationis true, or ifMultiFactorAuthenticationRequiredorMultiFactorAuthenticationRoleNameare 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
OnSignInorOnSignInAsyncwithcontext.AddCustomRole(...).IsCustomRolesEnabledis forced totrueand cannot be set back tofalse(InvalidOperationException). Without a role, login is refused (AuthNoAppAccess).
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
| Step | Database mode | None mode |
|---|---|---|
| User lookup | FindByEmailAsync / FindByNameAsync | None. GetIdentityUserAsync and OnUnknownUserAsync are not called. |
User passed to OnSignIn(Async) | User from the database | Transient user, never attached to DbContext: UserName = login, Email = login (email login) or email claim from the provider. |
| Roles, claims, external logins | Stored in the database | Not stored. Provider tokens remain in the cookie. |
Identifier (sub) | AspNetUsers.Id | Random Guid at each login: nothing links two sessions of the same person. |
| Cookie | Sliding, security stamp re-read from the database every 30 minutes | Absolute expiration (SsoAuthenticationCookieLifeTime), no re-reading. |
| OpenIddict codes and tokens | Principal reconstructed from the database, codes stored in OpenIddictTokens | Principal copied from the cookie, no rows in OpenIddictTokens. |
| OpenIddict authorization | Permanent | AdHoc (random identifier, client, scopes), deleted by the Quartz purge. |
| Refresh token | Issued if the client requests offline_access | Never issued: the offline_access scope is removed. |
| Re-login (popup) | Comparison of identifiers in the database | The provider's login must match the session cookie. |
Consequences
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 theDisplayNameclaim.- 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
Externalcannot use this mode: no permanent authorization corresponds to a random identifier.
Unavailable functions
| Function | Behavior |
|---|---|
CreateUserAsync, password reset and change | NotSupportedException. |
| MFA | Refused at startup (see MFA). |
OAuth clients requesting offline_access | The scope is removed: no refresh token. The Lagoon Blazor client renews its tokens without it. |
Personal Remote grid profiles | LgSettings.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 user | Any 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.