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

@@ -192,8 +192,12 @@ async function callback(req, res) {
// Auto-provision a `player` from an SSO profile when no identity is linked yet
// and registration allows SSO sign-up. Derives a unique username (reserved-name
// safe) with a bounded retry against the UNIQUE index, captures the provider
// email, links the identity, and audit-logs the provision. Returns the new user,
// or null if a unique username couldn't be found.
// email, links the identity, and audit-logs the provision.
//
// Returns { user } on success, or { error } naming why it failed. It used to
// return the user or a bare null, which was enough while username was the only
// unique index; since Phase 1b there are two ways to fail and they need different
// things said to the person in front of the browser.
async function provisionSsoPlayer(req, providerId, profile) {
const base = usernamePolicy.deriveUsernameBase(profile)
for (let attempt = 0; attempt < PROVISION_MAX_TRIES; attempt++) {
@@ -203,9 +207,17 @@ async function provisionSsoPlayer(req, providerId, profile) {
username: candidate,
role: 'player',
email: profile.email || null,
// The built-in providers only return an email the IdP has verified, so
// treat a supplied address as verified (skips the eventual re-verify).
emailVerified: Boolean(profile.email),
// Honour what the IdP actually ASSERTED, not the mere presence of an
// address. The old `Boolean(profile.email)` marked every SSO address
// verified, which made email_verified too weak a signal to mean anything
// (§0.6 finding 3). An IdP that omits the claim leaves the address
// unverified and the user proves it through the ordinary flow.
//
// Forward-only, by decision: existing rows keep the verified flag they
// were given. Retroactively demoting live users is the G22 mistake — a
// safe default applied backwards to a running system without telling
// anyone.
emailVerified: profile.emailVerified === true,
})
await userIdentities.link({
userId: user.id,
@@ -215,8 +227,24 @@ async function provisionSsoPlayer(req, providerId, profile) {
})
await activity.log({ req, userId: user.id, action: 'auth.sso.provision', detail: { provider: providerId } })
log.info('sso player provisioned', { provider: providerId, id: user.id, username: user.username })
return user
return { user }
} catch (err) {
// An EMAIL collision can never be cleared by trying another username, so
// retrying is not merely useless — it burns every candidate and returns
// null, and the log then blames usernames for a conflict that was never
// about them (§0.6 finding 2). Stop, and say which it was.
//
// This is not the enumeration surface the register form is: the caller has
// already authenticated with the IdP, and the address is one the IdP
// asserted for them. Naming the real reason here is what makes the failure
// diagnosable instead of opaque.
if (users.isDuplicateEmail(err)) {
log.warn('sso provision: address already held by another account', {
provider: providerId,
subject: profile.subject,
})
return { error: 'email_in_use' }
}
// Username collided with a concurrent/existing account — try the next
// suffix. Any other error is real; propagate it.
if (users.isDuplicateUsername(err)) continue
@@ -224,7 +252,7 @@ async function provisionSsoPlayer(req, providerId, profile) {
}
}
log.error('sso provision: exhausted username candidates', { provider: providerId, base })
return null
return { error: 'error' }
}
// Trusted-device skip for the SSO paths — the exact analogue of the check in
@@ -267,8 +295,9 @@ async function finishLogin(req, res, providerId, kind, tx, profile) {
log.warn('sso login refused: no linked account', { provider: providerId })
return res.redirect(loginError('not_linked', portal))
}
user = await provisionSsoPlayer(req, providerId, profile)
if (!user) return res.redirect(loginError('error', portal))
const provisioned = await provisionSsoPlayer(req, providerId, profile)
if (provisioned.error) return res.redirect(loginError(provisioned.error, portal))
user = provisioned.user
}
// Status gate (parity with local login): a disabled/banned account can't
@@ -384,12 +413,12 @@ async function resolveMobileSsoUser(req, res, sess, providerId, profile) {
res.redirect(appError(sess, 'not_linked'))
return null
}
const user = await provisionSsoPlayer(req, providerId, profile)
if (!user) {
res.redirect(appError(sess, 'error'))
const provisioned = await provisionSsoPlayer(req, providerId, profile)
if (provisioned.error) {
res.redirect(appError(sess, provisioned.error))
return null
}
return user
return provisioned.user
}
async function finishMobileLogin(req, res, providerId, kind, tx, profile) {
@@ -535,6 +564,11 @@ async function finishLink(req, res, providerId, tx, profile) {
}
module.exports = {
// Exported for tests only. The behaviour that matters is a COUNT — on an email
// conflict it must stop rather than work through every username candidate — and
// that is not observable through the route handlers without stubbing most of the
// OAuth flow to watch a loop it never reaches.
provisionSsoPlayer,
listProviders,
start,
linkStart,