// ── 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 }