Files
website/server/src/router/v1/auth/emailVerify.controller.js
wtclaude fbb4b0bd91
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
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>
2026-08-29 01:53:50 -05:00

101 lines
4.8 KiB
JavaScript

// ── Email-address verification (public, token-gated) ───────────────────────
//
// The confirm half of the Phase 1b change-and-verify flow. The request half is
// authenticated and lives on /auth/me/account/email; this half is deliberately
// NOT, because the link is opened from a mailbox, routinely on a device that is
// not logged in — requiring a session here would strand exactly the users the
// flow exists to serve.
//
// That is safe because the token IS the proof: it is opaque, single-use,
// short-lived, stored only as a sha256, and it carries the user and the address
// it was minted for. Using it installs an address on that account and does
// nothing else — it grants no session, no access, and no way to read anything.
// Compare passwordReset.controller, which is the same posture for a strictly
// more powerful capability.
//
// GET /auth/email/verify/:token -> validate the link so the page can render
// POST /auth/email/verify/:token -> install the address
const emailVerifications = require('../../../model/emailVerifications/emailVerifications.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const log = require('../../../utils/logger')('auth-email-verify')
const INVALID = 'This confirmation link is invalid or has expired.'
// GET /auth/email/verify/:token — validate a link so the page can render. 404 for
// anything not currently usable, never distinguishing expired from used from
// never-was.
async function lookup(req, res) {
try {
const row = await emailVerifications.findValidByToken(req.params.token)
if (!row) return res.status(404).json({ message: INVALID })
const user = await users.getById(row.user_id)
if (!user) return res.status(404).json({ message: INVALID })
// The address is echoed because the person holding this link is the person it
// was mailed to — they already know it. The username tells them which account
// they are about to attach it to.
return res.json({ username: user.username, email: row.email })
} catch (err) {
log.error('lookup', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// POST /auth/email/verify/:token — install the address.
async function confirm(req, res) {
try {
const row = await emailVerifications.findValidByToken(req.params.token)
if (!row) return res.status(404).json({ message: INVALID })
// Consume first: if we lost a double-submit race, stop before touching the
// account so a spent link cannot be replayed.
const won = await emailVerifications.consume(row.id)
if (!won) return res.status(404).json({ message: INVALID })
let installed
try {
installed = await users.promotePendingEmail(row.user_id, row.email)
} catch (err) {
// The UNIQUE index is the arbiter, and it fires here rather than at request
// time because a pending address reserves nothing: between staging and
// confirming, someone else may have verified the same address first.
//
// ANTI-ENUMERATION: the answer is the generic INVALID, identical to an
// expired or already-used link. Saying "that address is taken" would turn
// this endpoint into an oracle for which addresses hold accounts — the same
// posture passwordReset.controller keeps. The real reason is logged, never
// returned; note the driver's message embeds the address, which is another
// reason it must not travel to a client.
if (users.isDuplicateEmail(err)) {
log.warn('email verification lost to an existing address', { userId: row.user_id })
await users.clearPendingEmail(row.user_id).catch(() => {})
return res.status(404).json({ message: INVALID })
}
throw err
}
// The guard rejected it: the user has since asked for a different address, so
// this token is stale even though it had not expired. Same generic answer.
if (!installed) {
log.info('email verification superseded by a later request', { userId: row.user_id })
return res.status(404).json({ message: INVALID })
}
// Retire any other outstanding links for this user — one address is now proved
// and the others must not be installable behind the user's back.
await emailVerifications.invalidatePendingForUser(row.user_id)
await activity.log({ req, userId: row.user_id, action: 'account.email.verified' })
log.info('account email verified', { userId: row.user_id, ip: req.ip })
// No session is issued: this proves control of a mailbox, not of an account.
return res.json({ ok: true, message: 'Your email address has been confirmed.' })
} catch (err) {
log.error('confirm', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { lookup, confirm }