Problem
normalizeProviderUserId folds email and username identifiers with .toLowerCase() and nothing else.
It does not normalize Unicode, so text that is visually identical but differently encoded is treated as two distinct identifiers.
ü has two encodings: U+00FC precomposed, and u + U+0308 (combining diaeresis). macOS and some IME and paste paths emit the decomposed form, so which one a user produces depends on their device, not on what they typed.
const precomposed = "jürgen@münchen.de";
const decomposed = precomposed.normalize("NFD");
precomposed === decomposed; // false
precomposed.toLowerCase() === decomposed.toLowerCase(); // false <- what we do today
Consequence: the same person signing up on two devices can end up with two accounts that render identically everywhere in the UI, and neither the unique constraint on providerUserId nor any error message will indicate anything is wrong.
RFC 6532 section 3.1 recommends NFC for exactly this reason.
Scope
Both branches of the email / username case in normalizeProviderUserId are affected, and username is the more exposed of the two:
So decomposed identifiers already exist in production databases
Why this is not a one-line fix
Adding .normalize("NFC") to normalizeProviderUserId would lock out every existing user whose stored identifier is decomposed, silently and unrecoverably.
Every lookup path goes through createProviderId, so normalizing on read without migrating the stored data means the row stops matching:
login.ts normalizes the submitted identifier, misses the stored row, returns "Invalid credentials"
requestPasswordReset.ts performs the same lookup, so password reset misses too, and fails silently by design
- The row is still in the database, just unreachable
Breaking change concern
This is a breaking change for affected deployments.
It should explain how to backfill existing AuthIdentity.providerUserId rows for the email and username providers.
It should also warn that collisions can exist: if two existing rows normalize to the same value, they are duplicate accounts that the migration has to reconcile.
Problem
normalizeProviderUserIdfolds email and username identifiers with.toLowerCase()and nothing else.It does not normalize Unicode, so text that is visually identical but differently encoded is treated as two distinct identifiers.
ühas two encodings:U+00FCprecomposed, andu+U+0308(combining diaeresis). macOS and some IME and paste paths emit the decomposed form, so which one a user produces depends on their device, not on what they typed.Consequence: the same person signing up on two devices can end up with two accounts that render identically everywhere in the UI, and neither the unique constraint on
providerUserIdnor any error message will indicate anything is wrong.RFC 6532 section 3.1 recommends NFC for exactly this reason.
Scope
Both branches of the
email/usernamecase innormalizeProviderUserIdare affected, and username is the more exposed of the two:Username has never had any format validation.
ensureValidUsernameonly checks presence, so usernames have always been arbitrary Unicode of arbitrary length.Email rejects non-ASCII domains today, but non-ASCII local parts have been getting through: the pre-Accept internationalized emails. Fix uppercase and anchor bugs in email auth. #4695 regex was unanchored, so it matched on a substring and accepted both encodings identically:
josé.silva@gmail.comjürgen@example.comAfter Accept internationalized emails. Fix uppercase and anchor bugs in email auth. #4695 the validator accepts both encodings deliberately.
So decomposed identifiers already exist in production databases
Why this is not a one-line fix
Adding
.normalize("NFC")tonormalizeProviderUserIdwould lock out every existing user whose stored identifier is decomposed, silently and unrecoverably.Every lookup path goes through
createProviderId, so normalizing on read without migrating the stored data means the row stops matching:login.tsnormalizes the submitted identifier, misses the stored row, returns "Invalid credentials"requestPasswordReset.tsperforms the same lookup, so password reset misses too, and fails silently by designBreaking change concern
This is a breaking change for affected deployments.
It should explain how to backfill existing
AuthIdentity.providerUserIdrows for theemailandusernameproviders.It should also warn that collisions can exist: if two existing rows normalize to the same value, they are duplicate accounts that the migration has to reconcile.