feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s

Makes `users.email` unique, de-duplicates the addresses an upgrade will find,
and builds the self-service change-and-verify flow that did not exist.

The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED`
column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan
specified. Every case-insensitive collation this server offers is also
accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are
two different mailboxes. The plan's index would have refused the second address
forever and the de-duplication would have nulled a legitimate account's.

A requested address is STAGED in `email_pending` and only a tokened link
installs it, so a typo cannot silently redirect account-recovery mail.

`isDuplicateUsername()` now distinguishes the two indexes. All five call sites
branch on it; each answers differently on purpose, because a public form, an
IdP callback, a half-completed invite and an admin screen do not owe the same
person the same amount of truth.

SSO reads the IdP's actual `email_verified`/`verified` claim instead of
inferring verification from an address merely being present.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 01:53:50 -05:00
parent c2e4df5b3d
commit fbb4b0bd91
44 changed files with 3024 additions and 59 deletions

View File

@@ -37,7 +37,7 @@ class BaseProvider {
}
// Complete an SSO redirect flow: exchange the callback code for a normalized
// user profile ({ subject, email, name }).
// user profile ({ subject, email, emailVerified, name }).
// eslint-disable-next-line no-unused-vars
async handleCallback(params) {
throw new Error(`handleCallback() not implemented for provider '${this.id}'`)
@@ -49,7 +49,7 @@ class BaseProvider {
throw new Error(`getUserProfile() not implemented for provider '${this.id}'`)
}
// Normalize a raw external profile to { subject, email, name }.
// Normalize a raw external profile to { subject, email, emailVerified, name }.
// eslint-disable-next-line no-unused-vars
mapUser(profile) {
throw new Error(`mapUser() not implemented for provider '${this.id}'`)

View File

@@ -23,7 +23,14 @@ class DiscordProvider extends OAuth2Provider {
}
normalizeProfile(p = {}) {
// global_name is the new display name; fall back to the legacy username.
return { subject: p.id, email: p.email || null, name: p.global_name || p.username || null }
return {
subject: p.id,
email: p.email || null,
// Discord spells the claim `verified` rather than `email_verified`, and it
// means exactly this: the user confirmed the address with Discord.
emailVerified: p.verified === true,
name: p.global_name || p.username || null,
}
}
}

View File

@@ -30,6 +30,10 @@ class GenericOidcProvider extends OAuth2Provider {
return {
subject: p.sub || p.id || p.user_id || p.uid || null,
email: p.email || null,
// The standard OIDC claim. An IdP that omits it has not asserted anything,
// so the address stays unverified and the user proves it the ordinary way —
// absent is treated as false, never as true.
emailVerified: p.email_verified === true || p.email_verified === 'true',
name: p.name || p.preferred_username || p.username || p.email || null,
}
}

View File

@@ -27,7 +27,15 @@ class GoogleProvider extends OAuth2Provider {
return { access_type: 'online', prompt: 'select_account' }
}
normalizeProfile(p = {}) {
return { subject: p.sub, email: p.email || null, name: p.name || p.email || null }
return {
subject: p.sub,
email: p.email || null,
// Google's OIDC userinfo carries the standard `email_verified` claim. Read
// it rather than inferring verification from the mere presence of an
// address, which is what this code used to do (ENGAGEMENT.md §0.6/1b).
emailVerified: p.email_verified === true || p.email_verified === 'true',
name: p.name || p.email || null,
}
}
}