Skip to content

Storage and migrations ​

Provisioning is opt-in. The package is fully usable with no local user table at all, letting the identity provider own every user.

Add it when you want a row of your own to hang data off.

Two ways to hold the tables ​

Our context, when you would rather not touch yours:

csharp
builder.Services.AddToamaisutaaDbContext(db => db.UseNpgsql(connectionString,
    npgsql => npgsql.MigrationsAssembly("Toamaisutaa.EntityFrameworkCore.Migrations.Postgres")));

Yours, when you would rather keep one database context:

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
    modelBuilder.ApplyToamaisutaaConfiguration(Database);
}
csharp
builder.Services.AddToamaisutaaEntityFrameworkStores<YourDbContext>();

The entity configurations are public, so you can also apply them individually and call .ToTable() afterwards to rename anything. Generate the migration in your own project - the shipped migration packages only cover ToamaisutaaDbContext.

Why migrations ship as separate packages ​

EF Core cannot hold two providers' migration sets and model snapshots in one assembly. So each provider gets its own package, and the consumer names it:

ProviderPackageOptions callMigrations assembly
PostgreSQL…Migrations.PostgresUseNpgsqlToamaisutaa.EntityFrameworkCore.Migrations.Postgres
SQLite…Migrations.SqliteUseSqliteToamaisutaa.EntityFrameworkCore.Migrations.Sqlite
SQL Server…Migrations.SqlServerUseSqlServerToamaisutaa.EntityFrameworkCore.Migrations.SqlServer
MySQL…Migrations.MySqlUseMySQLToamaisutaa.EntityFrameworkCore.Migrations.MySql
csharp
// PostgreSQL
db.UseNpgsql(cs, o => o.MigrationsAssembly("Toamaisutaa.EntityFrameworkCore.Migrations.Postgres"));

// SQL Server
db.UseSqlServer(cs, o => o.MigrationsAssembly("Toamaisutaa.EntityFrameworkCore.Migrations.SqlServer"));

// MySQL
db.UseMySQL(cs, o => o.MigrationsAssembly("Toamaisutaa.EntityFrameworkCore.Migrations.MySql"));

// SQLite
db.UseSqlite(cs, o => o.MigrationsAssembly("Toamaisutaa.EntityFrameworkCore.Migrations.Sqlite"));

Install only the one you use. Each pulls its own database driver, and none of them is a dependency of Toamaisutaa.EntityFrameworkCore itself.

A note on the MySQL provider ​

The MySQL package builds on Oracle's MySql.EntityFrameworkCore, not the more commonly used Pomelo. That is not a judgement about either: Pomelo has no EF Core 10 release, and its latest version pins Microsoft.EntityFrameworkCore.Relational to [9.0.0, 9.0.999], so it cannot coexist with the rest of this package. If Pomelo ships for EF Core 10 and you would rather use it, the swap is a provider package and a regenerated migration - nothing in the schema changes.

Subjects are case-sensitive, and SQL Server and MySQL are not ​

An OpenID Connect subject is compared exactly. The default collations on SQL Server and MySQL ignore case, and MySQL's ignores accents as well, so the unique index on (ProviderKey, Subject) treated alice and ALICE as one subject and the second could never be provisioned.

The shipped migrations give the column a binary collation on those two - Latin1_General_100_BIN2 and utf8mb4_bin - and existing rows keep their values. PostgreSQL and SQLite compare exactly already and are left alone.

With your own context, pass its Database so the model knows which provider it is for, then generate the migration:

csharp
modelBuilder.ApplyToamaisutaaConfiguration(Database);

On MySQL, check the generated migration before applying it. MySql.EntityFrameworkCore leaves the collation out of the MODIFY it generates, so the migration applies and changes nothing; the shipped one is written as SQL for that reason:

sql
ALTER TABLE `ToamaisutaaExternalLogins` MODIFY `Subject` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL;

A database left comparing without case still refuses the second subject, with an error naming the collation rather than a bare conflict.

Not using Entity Framework at all ​

Toamaisutaa.EntityFrameworkCore is one implementation of a set of interfaces, not the storage layer. Toamaisutaa.Core depends on the interfaces and never on EF, so a deployment on Dapper, a document store, or an existing schema you do not control can implement them instead and register those in place of AddToamaisutaaEntityFrameworkStores.

InterfaceHolds
IUserStoreThe local user row, and the read-or-create on first sight of a subject
IExternalLoginStoreOne (provider, subject) pair per external identity
IPasswordCredentialStoreThe local credential, and the lockout counters
IRefreshTokenStoreRefresh tokens, hashed, grouped into families
IPasswordResetTokenStoreSingle-use reset tokens, hashed
IInvitationTokenStoreSingle-use invitation tokens, hashed
IEmailVerificationTokenStoreSingle-use email verification tokens, hashed, each naming the address it proves
ITwoFactorStoreOne TOTP enrolment per user
IRecoveryCodeStoreHashed single-use recovery codes
ITwoFactorChallengeStoreHalf-finished sign-ins
ITrustedDeviceStoreTrusted device families
IMagicLinkTokenStoreSingle-use magic-link tokens, hashed
IPasskeyCredentialStoreRegistered passkeys, one row per credential
IPasskeyChallengeStoreOutstanding passkey registration and assertion challenges

An IPasswordCredentialStore of your own should write only what changed and throw CredentialConcurrencyException when the row moved since it was read, the way the EF store does with concurrency tokens. The flows catch it, read the row again and reapply their change. A store that writes the whole row blindly still works, but parallel wrong passwords then all write the same count and the lockout never arrives.

IRefreshTokenStore.MarkRotatedAsync and ITrustedDeviceStore.MarkRotatedAsync return whether this call is the one that moved the row from live to rotated. Make it a single conditional write - WHERE Id = @id AND RotatedAt IS NULL AND RevokedAt IS NULL - and return whether a row changed. Returning true unconditionally lets two requests exchange one token at once, forking the session or the device trust with no reuse ever detected.

Every MarkConsumedAsync - reset, magic-link, invitation and email-verification tokens, recovery codes, two-factor and passkey challenges - follows the same rule: one write conditional on ConsumedAt IS NULL, returning whether it spent the row. The flows act on nothing until it says true, which is what makes single use hold when the same link or code arrives twice at once.

ITwoFactorStore.RecordUsedStepAsync is the TOTP version of the same thing: write the step only where the stored one is null or lower, and return whether a row changed. That makes one code good for one request, and stops a late write moving the step backwards.

ITwoFactorStore.UpdateFailedAttemptsAsync holds the wrong-code count for an account with no password credential. Write it only where the stored count still equals the expected one, and return whether a row changed; the flows re-read and retry, so parallel wrong codes each count.

Register whichever the features you use require - the startup checks name the missing one rather than failing at the first request:

csharp
builder.Services.AddScoped<IUserStore, YourUserStore>();
builder.Services.AddScoped<IRefreshTokenStore, YourRefreshTokenStore>();

Breaking: IUserStore gained SetUserNameAsync

Completing a reserved invitation sets a user name on a row that was created with only an email, and no existing method could write it. A custom IUserStore needs the new member before it compiles against this version.

Breaking: IUserStore gained SetEmailAsync

Email verification writes the proven address onto the credential, and the profile field the notifiers address their mail to has to follow it. A custom IUserStore needs the new member before it compiles against this version.

Three things the EF implementations do that yours must also do, because Core relies on them:

  • Tokens are looked up by hash, never by value. Every FindByHashAsync takes an unsalted SHA-256 of the token and expects an exact match.
  • Rotation and revocation are recorded, not deleted. MarkRotatedAsync has to leave the row findable, because presenting an already-rotated token is the theft signal that revokes the family. A store that deletes on rotation turns a stolen-token detector into a silent no-op.
  • Instants are compared, so they must be range-queryable. The EF stores keep them as Unix milliseconds for the reason below.

The tables ​

TableHolds
ToamaisutaaUsersThe local user: display name, email, avatar, timestamps
ToamaisutaaExternalLoginsOne (provider, subject) pair per external identity, unique
ToamaisutaaPasswordCredentialsThe local credential, one per user at most
ToamaisutaaRefreshTokensIssued refresh tokens, hashed, grouped into families
ToamaisutaaPasswordResetTokensSingle-use reset tokens, hashed
ToamaisutaaInvitationTokensSingle-use invitation tokens, hashed
ToamaisutaaEmailVerificationTokensSingle-use email verification tokens, hashed, each naming the address it proves
ToamaisutaaMagicLinkTokensSingle-use emailed sign-in tokens, hashed
ToamaisutaaUserTwoFactorsOne TOTP enrolment per user, its secret encrypted at rest
ToamaisutaaRecoveryCodesHashed single-use recovery codes
ToamaisutaaTwoFactorChallengesHalf-finished sign-ins waiting on a second factor
ToamaisutaaPasskeyCredentialsRegistered WebAuthn credentials, their public keys and signature counters
ToamaisutaaPasskeyChallengesWebAuthn ceremonies in flight, with the options that were sent

Credentials live in their own table rather than as columns on the user, and the reason is worth knowing: ToamaisutaaUsers.Email is a profile field that OIDC provisioning rewrites whenever the token's claim changes. If that same column were the unique local-login identifier, an administrator editing an email in your directory would silently change what someone types into your login form - and a collision would throw out of an unrelated request. A login identifier and a profile field have different rules.

ToamaisutaaUsers.Email is therefore not unique, permanently. The model is multi-provider: one person with accounts at two identity providers is two rows, legitimately sharing an address.

When the profile is written ​

ProfileSyncMode decides how often the stored row is refreshed from the token's claims:

ModeBehaviour
NeverWrite the profile once, at creation
FirstSignInOnlyThe same, for when "only at creation" is the intent rather than the side effect
OnChangeDefault. Write only when a mapped claim actually differs
EveryRequestWrite on every request

OnChange exists because the obvious implementation - refresh the row on every authenticated request - costs a write per request forever. It also handles the first-sign-in race: two concurrent requests for a never-seen subject both try to create, the unique index rejects one, and it re-reads rather than throwing.

Timestamps ​

Every instant is stored as Unix milliseconds in a signed integer column, not a provider-native timestamp. SQLite has no timestamp type, so EF keeps a DateTimeOffset as text and then declines to translate < or > on it - correctly, since values written with different offsets do not sort right as strings. An integer sorts identically on both providers and can be range-queried on both.

Two things change on a round trip, both on purpose:

  • The offset is discarded and the instant is kept. 12:00+02:00 reads back as 10:00+00:00 - the same moment, described from UTC.
  • The resolution is milliseconds. .1683914 reads back as .168.

Both are right for audit timestamps, and both are visible enough that someone will notice.

Made with care by PianoNic.