feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user