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:
@@ -24,6 +24,12 @@ test('/auth/me/account* rejects unauthenticated callers with 401', async () => {
|
||||
['GET', '/api/v1/auth/me/account/identities'],
|
||||
['PATCH', '/api/v1/auth/me/account/username', { username: 'someone' }],
|
||||
['PATCH', '/api/v1/auth/me/account/password', { newPassword: 'abcd1234' }],
|
||||
// Engagement Phase 1b — the email change/verify request half is self-service
|
||||
// and must be gated exactly like the rest. (The CONFIRM half is public by
|
||||
// design and lives at /auth/email/verify/:token, tested separately.)
|
||||
['PATCH', '/api/v1/auth/me/account/email', { email: 'new@example.com' }],
|
||||
['POST', '/api/v1/auth/me/account/email/resend'],
|
||||
['DELETE', '/api/v1/auth/me/account/email/pending'],
|
||||
['POST', '/api/v1/auth/me/account/totp/setup'],
|
||||
['POST', '/api/v1/auth/me/account/totp/enable', { code: '123456' }],
|
||||
['DELETE', '/api/v1/auth/me/account/identities/google'],
|
||||
|
||||
194
server/test/emailCollisionSurfaces.test.js
Normal file
194
server/test/emailCollisionSurfaces.test.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// Engagement Phase 1b — how each of the five write paths answers a duplicate
|
||||
// EMAIL, now that `users` has two unique indexes.
|
||||
//
|
||||
// Before this phase every one of them either misreported the collision as a
|
||||
// username clash or fell through to an opaque 500. The answers are deliberately
|
||||
// NOT uniform, and the differences are the point:
|
||||
//
|
||||
// register generic 400, unscored — a public form; the truth would make
|
||||
// account existence queryable, and
|
||||
// scoring an honest typo would push a
|
||||
// real user toward an IP ban
|
||||
// SSO provision stops, names the reason — the caller already authenticated
|
||||
// with the IdP; retrying usernames can
|
||||
// never clear an email conflict
|
||||
// invite accept 409, explains — the invitee has already clicked a
|
||||
// link and typed a password
|
||||
// admin create 409, names the field — an admin can already list every
|
||||
// admin update 409, names the field account, so there is nothing to leak
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const authCtrl = require('../src/router/v1/auth/auth.controller')
|
||||
const inviteCtrl = require('../src/router/v1/auth/invite.controller')
|
||||
const adminCtrl = require('../src/router/v1/admin/admin.controller')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const invites = require('../src/model/invites/invites.model')
|
||||
const settings = require('../src/model/settings/settings.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const botScore = require('../src/middleware/botScore')
|
||||
const loginProtection = require('../src/middleware/loginProtection')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(c) {
|
||||
this.statusCode = c
|
||||
return this
|
||||
},
|
||||
json(b) {
|
||||
this.body = b
|
||||
return this
|
||||
},
|
||||
cookie() {
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const patched = []
|
||||
function stub(obj, name, fn) {
|
||||
patched.push([obj, name, obj[name]])
|
||||
obj[name] = fn
|
||||
}
|
||||
afterEach(() => {
|
||||
while (patched.length) {
|
||||
const [obj, name, fn] = patched.pop()
|
||||
obj[name] = fn
|
||||
}
|
||||
})
|
||||
|
||||
// The exact error the mariadb connector raises for each index, captured from
|
||||
// MariaDB 11.8. Note the address is inside the message: that is why none of these
|
||||
// paths may echo it.
|
||||
function dupEmailError(value = 'taken@example.com') {
|
||||
const err = new Error(
|
||||
`(conn:60, no: 1062, SQLState: 23000) Duplicate entry '${value}' for key 'uq_users_email_norm'\n` +
|
||||
`sql: INSERT INTO users ... - parameters:['someone','${value}']`,
|
||||
)
|
||||
err.code = 'ER_DUP_ENTRY'
|
||||
err.errno = 1062
|
||||
err.sqlMessage = `Duplicate entry '${value}' for key 'uq_users_email_norm'`
|
||||
return err
|
||||
}
|
||||
|
||||
function dupUsernameError() {
|
||||
const err = new Error("(conn:60, no: 1062, SQLState: 23000) Duplicate entry 'someone' for key 'username'")
|
||||
err.code = 'ER_DUP_ENTRY'
|
||||
err.errno = 1062
|
||||
err.sqlMessage = "Duplicate entry 'someone' for key 'username'"
|
||||
return err
|
||||
}
|
||||
|
||||
let scored
|
||||
|
||||
beforeEach(() => {
|
||||
scored = []
|
||||
stub(activity, 'log', async () => {})
|
||||
stub(botScore, 'recordHoneypot', (ip) => scored.push(['honeypot', ip]))
|
||||
stub(botScore, 'recordLoginFailure', (ip) => scored.push(['loginFailure', ip]))
|
||||
stub(loginProtection, 'recordFailure', (ip) => scored.push(['backoff', ip]))
|
||||
})
|
||||
|
||||
// ── register: generic, and NOT scored ──────────────────────────────────────
|
||||
|
||||
test('register answers a duplicate email generically and never says "username"', async () => {
|
||||
stub(settings, 'getRegistrationMode', async () => 'password')
|
||||
stub(users, 'createUser', async () => {
|
||||
throw dupEmailError()
|
||||
})
|
||||
const res = mockRes()
|
||||
await authCtrl.register(
|
||||
{ body: { username: 'newperson', password: 'abcd1234', email: 'taken@example.com' }, ip: '9.9.9.9' },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 400, 'not the 409 a username clash gets - the shape itself must not distinguish')
|
||||
assert.match(res.body.message, /Registration failed/i)
|
||||
assert.doesNotMatch(res.body.message, /username/i, 'must not misattribute to the field they did NOT collide on')
|
||||
assert.doesNotMatch(res.body.message, /email/i, 'and must not confirm the address exists')
|
||||
assert.doesNotMatch(res.body.message, /taken@example\.com/, 'the address must never come back')
|
||||
})
|
||||
|
||||
test('a duplicate email at register feeds NOTHING to the bot scorer or the backoff', async () => {
|
||||
stub(settings, 'getRegistrationMode', async () => 'password')
|
||||
stub(users, 'createUser', async () => {
|
||||
throw dupEmailError()
|
||||
})
|
||||
await authCtrl.register(
|
||||
{ body: { username: 'newperson', password: 'abcd1234', email: 'taken@example.com' }, ip: '9.9.9.9' },
|
||||
mockRes(),
|
||||
)
|
||||
// A legitimate user typing a colleague's address is not an attacker. Scoring
|
||||
// this would walk them toward an automatic IP ban for an honest mistake.
|
||||
assert.deepEqual(scored, [], 'no bot score, no backoff')
|
||||
})
|
||||
|
||||
test('register still reports a genuine username clash as 409', async () => {
|
||||
stub(settings, 'getRegistrationMode', async () => 'password')
|
||||
stub(users, 'createUser', async () => {
|
||||
throw dupUsernameError()
|
||||
})
|
||||
const res = mockRes()
|
||||
await authCtrl.register({ body: { username: 'someone', password: 'abcd1234' }, ip: '9.9.9.9' }, res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.match(res.body.message, /username/i)
|
||||
})
|
||||
|
||||
// ── invite accept: survivable, and distinguishable from a username clash ───
|
||||
|
||||
test('invite accept explains a duplicate email instead of blaming the username', async () => {
|
||||
stub(invites, 'findValidByToken', async () => ({ id: 5, email: 'taken@example.com', role: 'player' }))
|
||||
stub(users, 'createUser', async () => {
|
||||
throw dupEmailError()
|
||||
})
|
||||
const res = mockRes()
|
||||
await inviteCtrl.acceptInvite(
|
||||
{ body: { username: 'newperson', password: 'abcd1234' }, params: { token: 't' }, ip: '9.9.9.9' },
|
||||
res,
|
||||
)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.match(res.body.message, /email address is already in use/i)
|
||||
assert.doesNotMatch(res.body.message, /username is already taken/i)
|
||||
})
|
||||
|
||||
// ── admin user CRUD: was an opaque 500, now a 409 that names the field ─────
|
||||
|
||||
test('admin createUser answers a duplicate email with 409, not a 500', async () => {
|
||||
stub(users, 'getRawByUsername', async () => null)
|
||||
stub(users, 'createUser', async () => {
|
||||
throw dupEmailError()
|
||||
})
|
||||
const res = mockRes()
|
||||
await adminCtrl.createUser({ body: { username: 'newperson', password: 'abcd1234', email: 'taken@example.com' } }, res)
|
||||
assert.equal(res.statusCode, 409, 'before Phase 1b this had no catch at all and became a 500')
|
||||
assert.match(res.body.message, /email address/i)
|
||||
})
|
||||
|
||||
test('admin updateUser answers a duplicate email with 409, not a 500', async () => {
|
||||
stub(users, 'getById', async () => ({ id: 3, username: 'existing', role: 'player', status: 'active' }))
|
||||
stub(users, 'update', async () => {
|
||||
throw dupEmailError()
|
||||
})
|
||||
const res = mockRes()
|
||||
await adminCtrl.updateUser({ params: { id: '3' }, body: { email: 'taken@example.com' } }, res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.match(res.body.message, /email address/i)
|
||||
})
|
||||
|
||||
test('admin createUser still reports a username clash as a username clash', async () => {
|
||||
stub(users, 'getRawByUsername', async () => null)
|
||||
stub(users, 'createUser', async () => {
|
||||
throw dupUsernameError()
|
||||
})
|
||||
const res = mockRes()
|
||||
await adminCtrl.createUser({ body: { username: 'someone', password: 'abcd1234' } }, res)
|
||||
assert.equal(res.statusCode, 409)
|
||||
assert.match(res.body.message, /Username already taken/i)
|
||||
})
|
||||
87
server/test/emailUniqueness.test.js
Normal file
87
server/test/emailUniqueness.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// Engagement Phase 1b — telling the two unique constraints on `users` apart.
|
||||
//
|
||||
// This is the piece the whole phase rests on: `users` grew a second unique index,
|
||||
// and until Phase 1b the duplicate-key test could not tell which one fired. Every
|
||||
// call site that creates or updates a user branches on these predicates, so a
|
||||
// wrong answer here means a duplicate email reported as a taken username, an SSO
|
||||
// sign-up retrying usernames against a conflict no username can clear, or an
|
||||
// opaque 500 on the admin user form.
|
||||
//
|
||||
// The error strings below are VERBATIM from MariaDB 11.8 through the mariadb Node
|
||||
// connector, captured against a real duplicate insert. The key name lives only in
|
||||
// the message text — the driver exposes no structured field for it — which is
|
||||
// exactly why this needs its own test: it is parsing, and parsing rots silently.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const users = require('../src/model/users/users.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// Shaped exactly as the connector delivers them, including the trailing `sql:`
|
||||
// section — which is also the reason these must never be echoed to a client: note
|
||||
// the bound parameters, and therefore the address, are in the text.
|
||||
function dupError(key, value) {
|
||||
const err = new Error(
|
||||
`(conn:60, no: 1062, SQLState: 23000) Duplicate entry '${value}' for key '${key}'\n` +
|
||||
`sql: INSERT INTO users (username, email) VALUES (?, ?) - parameters:['someone','${value}']`,
|
||||
)
|
||||
err.code = 'ER_DUP_ENTRY'
|
||||
err.errno = 1062
|
||||
err.sqlState = '23000'
|
||||
err.sqlMessage = `Duplicate entry '${value}' for key '${key}'`
|
||||
return err
|
||||
}
|
||||
|
||||
test('an email collision is reported as email, not username', () => {
|
||||
const err = dupError('uq_users_email_norm', 'taken@example.com')
|
||||
assert.equal(users.isDuplicateEmail(err), true)
|
||||
assert.equal(users.isDuplicateUsername(err), false, 'must NOT masquerade as a username collision')
|
||||
assert.equal(users.duplicateKey(err), 'uq_users_email_norm')
|
||||
})
|
||||
|
||||
test('a username collision is still reported as username', () => {
|
||||
const err = dupError('username', 'someone')
|
||||
assert.equal(users.isDuplicateUsername(err), true)
|
||||
assert.equal(users.isDuplicateEmail(err), false)
|
||||
assert.equal(users.duplicateKey(err), 'username')
|
||||
})
|
||||
|
||||
// The permissive fallback is deliberate. Only the case we can positively identify
|
||||
// — email — is carved out; anything else keeps the pre-Phase-1b behaviour so no
|
||||
// call site newly falls through to a 500 on a database whose index carries an
|
||||
// unexpected name.
|
||||
test('an unrecognised unique index keeps the old permissive behaviour', () => {
|
||||
const err = dupError('some_other_uq', 'x')
|
||||
assert.equal(users.isDuplicateUsername(err), true)
|
||||
assert.equal(users.isDuplicateEmail(err), false)
|
||||
})
|
||||
|
||||
test('a duplicate-key error the message does not name is treated as username', () => {
|
||||
const err = new Error('Duplicate entry - no key clause here')
|
||||
err.code = 'ER_DUP_ENTRY'
|
||||
err.errno = 1062
|
||||
assert.equal(users.duplicateKey(err), null)
|
||||
assert.equal(users.isDuplicateUsername(err), true)
|
||||
assert.equal(users.isDuplicateEmail(err), false)
|
||||
})
|
||||
|
||||
test('non-duplicate errors are neither', () => {
|
||||
for (const err of [null, undefined, new Error('boom'), { code: 'ER_NO_SUCH_TABLE' }]) {
|
||||
assert.equal(users.isDuplicateUsername(err), false)
|
||||
assert.equal(users.isDuplicateEmail(err), false)
|
||||
assert.equal(users.duplicateKey(err), null)
|
||||
}
|
||||
})
|
||||
|
||||
// errno alone, with no `code`, is how some driver paths surface it.
|
||||
test('errno 1062 without a code still counts', () => {
|
||||
const err = new Error("Duplicate entry 'a@b.com' for key 'uq_users_email_norm'")
|
||||
err.errno = 1062
|
||||
assert.equal(users.isDuplicateEmail(err), true)
|
||||
assert.equal(users.isDuplicateUsername(err), false)
|
||||
})
|
||||
278
server/test/emailVerification.test.js
Normal file
278
server/test/emailVerification.test.js
Normal file
@@ -0,0 +1,278 @@
|
||||
// Engagement Phase 1b — the change-and-verify flow, at the controller level.
|
||||
//
|
||||
// Every model call is monkeypatched, so no query runs. What is under test is the
|
||||
// DECISION-MAKING, and three properties in particular that no single unit of the
|
||||
// code enforces on its own:
|
||||
//
|
||||
// 1. Requesting a change never touches the live address. The account keeps
|
||||
// receiving password-reset mail at the address it had until a link proves the
|
||||
// new one. A regression here is silent, and only shows up when somebody
|
||||
// cannot recover their account.
|
||||
// 2. Confirming answers IDENTICALLY for every failure. Expired, already-used,
|
||||
// superseded, and "another account verified this address first" are one 404
|
||||
// with one message. Any divergence turns the endpoint into an oracle for
|
||||
// which addresses hold accounts.
|
||||
// 3. Changing the address is re-authenticated, with the SSO carve-out.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, beforeEach, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const account = require('../src/router/v1/auth/account.controller')
|
||||
const verify = require('../src/router/v1/auth/emailVerify.controller')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const emailVerifications = require('../src/model/emailVerifications/emailVerifications.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const mailer = require('../src/utils/mailer')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
function mockRes() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: null,
|
||||
status(c) {
|
||||
this.statusCode = c
|
||||
return this
|
||||
},
|
||||
json(b) {
|
||||
this.body = b
|
||||
return this
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Restore-on-teardown patching, keyed by owner+name so two modules may each carry
|
||||
// a function of the same name.
|
||||
const patched = []
|
||||
function stub(obj, name, fn) {
|
||||
patched.push([obj, name, obj[name]])
|
||||
obj[name] = fn
|
||||
}
|
||||
|
||||
let sent
|
||||
let staged
|
||||
let promoted
|
||||
|
||||
beforeEach(() => {
|
||||
sent = []
|
||||
staged = []
|
||||
promoted = []
|
||||
stub(activity, 'log', async () => {})
|
||||
stub(mailer, 'sendEmailVerification', async (args) => {
|
||||
sent.push(args)
|
||||
return { sent: true }
|
||||
})
|
||||
stub(users, 'setPendingEmail', async (id, email) => {
|
||||
staged.push([id, email])
|
||||
return 1
|
||||
})
|
||||
stub(users, 'clearPendingEmail', async () => 1)
|
||||
stub(users, 'promotePendingEmail', async (id, email) => {
|
||||
promoted.push([id, email])
|
||||
return true
|
||||
})
|
||||
stub(users, 'validatePassword', async (_u, pw) => pw === 'correct-horse')
|
||||
stub(emailVerifications, 'sendQuotaExhausted', async () => false)
|
||||
stub(emailVerifications, 'invalidatePendingForUser', async () => 0)
|
||||
stub(emailVerifications, 'create', async () => ({ id: 1, token: 'tok-abcdefgh' }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (patched.length) {
|
||||
const [obj, name, fn] = patched.pop()
|
||||
obj[name] = fn
|
||||
}
|
||||
})
|
||||
|
||||
const reqFor = (body, user = {}) => ({
|
||||
body,
|
||||
ip: '10.0.0.1',
|
||||
user: { id: 7, username: 'alice', ...user },
|
||||
})
|
||||
|
||||
// ── 1. The live address is never touched by a request ──────────────────────
|
||||
|
||||
test('requesting a change stages the address and leaves the live one alone', async () => {
|
||||
let updateCalled = false
|
||||
stub(users, 'update', async () => {
|
||||
updateCalled = true
|
||||
})
|
||||
stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'old@example.com' }))
|
||||
|
||||
const res = mockRes()
|
||||
await account.changeEmail(reqFor({ email: 'new@example.com', currentPassword: 'correct-horse' }), res)
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.deepEqual(staged, [[7, 'new@example.com']], 'the new address is STAGED')
|
||||
assert.equal(updateCalled, false, 'users.update must NOT be called - the live address stands')
|
||||
assert.equal(res.body.email_pending, 'new@example.com')
|
||||
assert.equal(sent.length, 1, 'a verification mail goes to the address being proved')
|
||||
assert.equal(sent[0].to, 'new@example.com')
|
||||
})
|
||||
|
||||
test('the verification link goes to the NEW address, never the old one', async () => {
|
||||
stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'old@example.com' }))
|
||||
const res = mockRes()
|
||||
await account.changeEmail(reqFor({ email: 'new@example.com', currentPassword: 'correct-horse' }), res)
|
||||
assert.equal(sent[0].to, 'new@example.com')
|
||||
assert.notEqual(sent[0].to, 'old@example.com')
|
||||
})
|
||||
|
||||
// ── 2. Re-authentication, with the SSO carve-out ───────────────────────────
|
||||
|
||||
test('a wrong current password is refused and stages nothing', async () => {
|
||||
stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'old@example.com' }))
|
||||
const res = mockRes()
|
||||
await account.changeEmail(reqFor({ email: 'new@example.com', currentPassword: 'wrong' }), res)
|
||||
assert.equal(res.statusCode, 400)
|
||||
assert.deepEqual(staged, [], 'nothing may be staged on a failed re-auth')
|
||||
assert.equal(sent.length, 0, 'and no mail may go out')
|
||||
})
|
||||
|
||||
test('an SSO-only account (no password hash) may change its address without one', async () => {
|
||||
stub(users, 'getRawById', async () => ({ id: 7, password_hash: null, email: null }))
|
||||
const res = mockRes()
|
||||
await account.changeEmail(reqFor({ email: 'first@example.com' }), res)
|
||||
assert.equal(res.statusCode, 200, 'the carve-out changePassword already makes, made here too')
|
||||
assert.deepEqual(staged, [[7, 'first@example.com']])
|
||||
})
|
||||
|
||||
test('setting the same address again is refused', async () => {
|
||||
stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'Same@Example.com' }))
|
||||
const res = mockRes()
|
||||
// Compared case-folded, because the uniqueness index folds case: this IS the
|
||||
// same mailbox, and staging it would mail the user a link to prove what they
|
||||
// have already proved.
|
||||
await account.changeEmail(reqFor({ email: 'same@example.com', currentPassword: 'correct-horse' }), res)
|
||||
assert.equal(res.statusCode, 400)
|
||||
assert.deepEqual(staged, [])
|
||||
})
|
||||
|
||||
// ── 3. Confirming: every failure answers identically ───────────────────────
|
||||
|
||||
const INVALID = 'This confirmation link is invalid or has expired.'
|
||||
|
||||
test('an unusable link 404s with the generic message', async () => {
|
||||
stub(emailVerifications, 'findValidByToken', async () => null)
|
||||
const res = mockRes()
|
||||
await verify.confirm({ params: { token: 'nope' }, ip: '1.2.3.4' }, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
assert.equal(res.body.message, INVALID)
|
||||
})
|
||||
|
||||
test('an address another account verified first answers the SAME 404', async () => {
|
||||
stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'taken@example.com' }))
|
||||
stub(emailVerifications, 'consume', async () => true)
|
||||
stub(users, 'promotePendingEmail', async () => {
|
||||
const err = new Error("Duplicate entry 'taken@example.com' for key 'uq_users_email_norm'")
|
||||
err.code = 'ER_DUP_ENTRY'
|
||||
err.errno = 1062
|
||||
err.sqlMessage = "Duplicate entry 'taken@example.com' for key 'uq_users_email_norm'"
|
||||
throw err
|
||||
})
|
||||
const res = mockRes()
|
||||
await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res)
|
||||
assert.equal(res.statusCode, 404, 'not a 409 - that would be an enumeration oracle')
|
||||
assert.equal(res.body.message, INVALID, 'byte-identical to an expired link')
|
||||
})
|
||||
|
||||
test('a superseded link answers the SAME 404', async () => {
|
||||
stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'stale@example.com' }))
|
||||
stub(emailVerifications, 'consume', async () => true)
|
||||
stub(users, 'promotePendingEmail', async () => false) // the guard rejected it
|
||||
const res = mockRes()
|
||||
await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
assert.equal(res.body.message, INVALID)
|
||||
})
|
||||
|
||||
test('a link that lost the double-use race answers the SAME 404', async () => {
|
||||
stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'a@example.com' }))
|
||||
stub(emailVerifications, 'consume', async () => false) // someone else consumed it first
|
||||
const res = mockRes()
|
||||
await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res)
|
||||
assert.equal(res.statusCode, 404)
|
||||
assert.equal(res.body.message, INVALID)
|
||||
assert.deepEqual(promoted, [], 'and must not touch the account')
|
||||
})
|
||||
|
||||
test('a good link installs the address and issues no session', async () => {
|
||||
stub(emailVerifications, 'findValidByToken', async () => ({ id: 1, user_id: 7, email: 'good@example.com' }))
|
||||
stub(emailVerifications, 'consume', async () => true)
|
||||
const res = mockRes()
|
||||
await verify.confirm({ params: { token: 'tok' }, ip: '1.2.3.4' }, res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.ok, true)
|
||||
assert.deepEqual(promoted, [[7, 'good@example.com']])
|
||||
// The response carries no token, cookie or user — proving control of a mailbox
|
||||
// is not proving control of an account.
|
||||
assert.equal(res.body.token, undefined)
|
||||
assert.equal(res.body.user, undefined)
|
||||
})
|
||||
|
||||
// ── 4. The send ceiling, and honest reporting when mail is off ─────────────
|
||||
|
||||
test('the per-user send ceiling refuses with 429 and sends nothing', async () => {
|
||||
stub(users, 'getRawById', async () => ({ id: 7, password_hash: null, email: null }))
|
||||
stub(emailVerifications, 'sendQuotaExhausted', async () => true)
|
||||
const res = mockRes()
|
||||
await account.changeEmail(reqFor({ email: 'new@example.com' }), res)
|
||||
assert.equal(res.statusCode, 429)
|
||||
assert.equal(sent.length, 0)
|
||||
})
|
||||
|
||||
test('unconfigured mail is reported honestly, and the address stays staged', async () => {
|
||||
stub(users, 'getRawById', async () => ({ id: 7, password_hash: null, email: null }))
|
||||
stub(mailer, 'sendEmailVerification', async () => ({ sent: false, reason: 'NOT_CONFIGURED' }))
|
||||
const res = mockRes()
|
||||
await account.changeEmail(reqFor({ email: 'new@example.com' }), res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.emailed, false)
|
||||
assert.equal(res.body.reason, 'NOT_CONFIGURED')
|
||||
assert.deepEqual(staged, [[7, 'new@example.com']], 'staged, so a later resend can work')
|
||||
})
|
||||
|
||||
test('resending with nothing pending is a 400, not a mail', async () => {
|
||||
stub(users, 'getRawById', async () => ({ id: 7, password_hash: 'h', email: 'a@example.com', email_pending: null }))
|
||||
const res = mockRes()
|
||||
await account.resendEmailVerification(reqFor({}), res)
|
||||
assert.equal(res.statusCode, 400)
|
||||
assert.equal(sent.length, 0)
|
||||
})
|
||||
|
||||
test('resending re-sends to the pending address', async () => {
|
||||
stub(users, 'getRawById', async () => ({
|
||||
id: 7,
|
||||
password_hash: 'h',
|
||||
email: 'a@example.com',
|
||||
email_pending: 'p@example.com',
|
||||
}))
|
||||
const res = mockRes()
|
||||
await account.resendEmailVerification(reqFor({}), res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(sent.length, 1)
|
||||
assert.equal(sent[0].to, 'p@example.com')
|
||||
})
|
||||
|
||||
test('cancelling clears the pending address AND retires its outstanding links', async () => {
|
||||
let cleared = false
|
||||
let retired = false
|
||||
stub(users, 'clearPendingEmail', async () => {
|
||||
cleared = true
|
||||
return 1
|
||||
})
|
||||
stub(emailVerifications, 'invalidatePendingForUser', async () => {
|
||||
retired = true
|
||||
return 1
|
||||
})
|
||||
const res = mockRes()
|
||||
await account.cancelEmailChange(reqFor({}), res)
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(cleared, true)
|
||||
// Both halves matter: clearing the column alone would leave a link already
|
||||
// sitting in a mailbox able to install the address the user just abandoned.
|
||||
assert.equal(retired, true, 'outstanding links must be retired too')
|
||||
})
|
||||
@@ -50,7 +50,16 @@ test('Google handleCallback exchanges code and normalizes the profile', async ()
|
||||
})
|
||||
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
|
||||
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' })
|
||||
assert.deepEqual(profile, { subject: '11550', email: 'alice@example.com', name: 'Alice' })
|
||||
// emailVerified is false because this userinfo document carries no
|
||||
// `email_verified` claim. Before engagement Phase 1b the presence of an address
|
||||
// was itself treated as verification, which is the bug that made the flag
|
||||
// meaningless — see ssoEmailVerified.test.js.
|
||||
assert.deepEqual(profile, {
|
||||
subject: '11550',
|
||||
email: 'alice@example.com',
|
||||
emailVerified: false,
|
||||
name: 'Alice',
|
||||
})
|
||||
})
|
||||
|
||||
test('Discord authorize URL + profile mapping (global_name → name, id → subject)', async () => {
|
||||
@@ -64,7 +73,8 @@ test('Discord authorize URL + profile mapping (global_name → name, id → subj
|
||||
'discord.com/api/users/@me': { id: '99', username: 'bob', global_name: 'Bob', email: 'bob@x.io' },
|
||||
})
|
||||
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb' })
|
||||
assert.deepEqual(profile, { subject: '99', email: 'bob@x.io', name: 'Bob' })
|
||||
// Discord spells the claim `verified`, and this fixture does not send it.
|
||||
assert.deepEqual(profile, { subject: '99', email: 'bob@x.io', emailVerified: false, name: 'Bob' })
|
||||
})
|
||||
|
||||
test('Generic OIDC provider uses configured endpoints and OIDC profile fields', async () => {
|
||||
@@ -82,7 +92,8 @@ test('Generic OIDC provider uses configured endpoints and OIDC profile fields',
|
||||
'idp.example/userinfo': { sub: 'abc', email: 'c@d.e', preferred_username: 'carol' },
|
||||
})
|
||||
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' })
|
||||
assert.deepEqual(profile, { subject: 'abc', email: 'c@d.e', name: 'carol' })
|
||||
// An IdP that omits the claim has asserted nothing: absent is false, never true.
|
||||
assert.deepEqual(profile, { subject: 'abc', email: 'c@d.e', emailVerified: false, name: 'carol' })
|
||||
})
|
||||
|
||||
test('handleCallback throws when the token exchange fails', async () => {
|
||||
|
||||
140
server/test/ssoEmailVerified.test.js
Normal file
140
server/test/ssoEmailVerified.test.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// Engagement Phase 1b — what SSO does with an email address.
|
||||
//
|
||||
// Two corrections, both of them things the old code got wrong quietly:
|
||||
//
|
||||
// 1. `emailVerified: Boolean(profile.email)` marked EVERY SSO address verified,
|
||||
// because an address was present. That made `email_verified` mean "we have an
|
||||
// address", which is not a fact about anything, and is why the de-duplication
|
||||
// resolves duplicates oldest-wins rather than verified-wins (§0.6 finding 3).
|
||||
// Now each provider reports the claim its IdP actually asserted.
|
||||
// 2. Provisioning retried usernames on ANY duplicate-key error. Once email is
|
||||
// unique that loop can never clear an email conflict — it burns every
|
||||
// candidate and returns "could not find a username", blaming usernames for a
|
||||
// conflict that was never about them (§0.6 finding 2).
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after, afterEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const GoogleProvider = require('../src/auth/providers/google.provider')
|
||||
const DiscordProvider = require('../src/auth/providers/discord.provider')
|
||||
const GenericOidcProvider = require('../src/auth/providers/genericOidc.provider')
|
||||
const sso = require('../src/router/v1/auth/sso.controller')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const userIdentities = require('../src/model/userIdentities/userIdentities.model')
|
||||
const activity = require('../src/model/activity/activity.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
// ── 1. Each provider reads its own spelling of the claim ───────────────────
|
||||
|
||||
test('Google reads the standard email_verified claim', () => {
|
||||
const p = new GoogleProvider({ id: 'google' })
|
||||
assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: true }).emailVerified, true)
|
||||
assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: false }).emailVerified, false)
|
||||
// Present-but-unasserted is NOT verified. This is the whole bug.
|
||||
assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com' }).emailVerified, false)
|
||||
})
|
||||
|
||||
test('Discord reads `verified`, which is how Discord spells it', () => {
|
||||
const p = new DiscordProvider({ id: 'discord' })
|
||||
assert.equal(p.normalizeProfile({ id: '1', email: 'a@b.com', verified: true }).emailVerified, true)
|
||||
assert.equal(p.normalizeProfile({ id: '1', email: 'a@b.com', verified: false }).emailVerified, false)
|
||||
assert.equal(p.normalizeProfile({ id: '1', email: 'a@b.com' }).emailVerified, false)
|
||||
})
|
||||
|
||||
test('a generic OIDC provider that omits the claim leaves the address unverified', () => {
|
||||
const p = new GenericOidcProvider({ id: 'custom' })
|
||||
assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: true }).emailVerified, true)
|
||||
// An IdP that asserts nothing has asserted nothing. Absent is false, never true.
|
||||
assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com' }).emailVerified, false)
|
||||
})
|
||||
|
||||
// Some IdPs stringify booleans in the userinfo document.
|
||||
test('the string "true" counts, anything else does not', () => {
|
||||
const p = new GenericOidcProvider({ id: 'custom' })
|
||||
assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: 'true' }).emailVerified, true)
|
||||
assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: 'yes' }).emailVerified, false)
|
||||
assert.equal(p.normalizeProfile({ sub: '1', email: 'a@b.com', email_verified: 1 }).emailVerified, false)
|
||||
})
|
||||
|
||||
test('every provider still returns the fields the rest of the flow reads', () => {
|
||||
const cases = [
|
||||
[new GoogleProvider({ id: 'google' }), { sub: 'g1', email: 'a@b.com', name: 'A' }],
|
||||
[new DiscordProvider({ id: 'discord' }), { id: 'd1', email: 'a@b.com', global_name: 'A' }],
|
||||
[new GenericOidcProvider({ id: 'custom' }), { sub: 'c1', email: 'a@b.com', name: 'A' }],
|
||||
]
|
||||
for (const [provider, raw] of cases) {
|
||||
const out = provider.normalizeProfile(raw)
|
||||
assert.ok(out.subject, `${provider.id} must still derive a subject`)
|
||||
assert.equal(out.email, 'a@b.com')
|
||||
assert.equal(typeof out.emailVerified, 'boolean', `${provider.id} must report a boolean, never undefined`)
|
||||
assert.ok('name' in out)
|
||||
}
|
||||
})
|
||||
|
||||
// ── 2. Provisioning stops on an email conflict instead of burning candidates ─
|
||||
|
||||
const patched = []
|
||||
function stub(obj, name, fn) {
|
||||
patched.push([obj, name, obj[name]])
|
||||
obj[name] = fn
|
||||
}
|
||||
afterEach(() => {
|
||||
while (patched.length) {
|
||||
const [obj, name, fn] = patched.pop()
|
||||
obj[name] = fn
|
||||
}
|
||||
})
|
||||
|
||||
function dupError(key, value) {
|
||||
const err = new Error(`Duplicate entry '${value}' for key '${key}'`)
|
||||
err.code = 'ER_DUP_ENTRY'
|
||||
err.errno = 1062
|
||||
err.sqlMessage = `Duplicate entry '${value}' for key '${key}'`
|
||||
return err
|
||||
}
|
||||
|
||||
const req = { ip: '1.2.3.4' }
|
||||
const profile = { subject: 'idp-1', email: 'taken@example.com', name: 'Someone', emailVerified: true }
|
||||
|
||||
test('an email conflict stops provisioning at the FIRST attempt', async () => {
|
||||
let attempts = 0
|
||||
stub(users, 'createUser', async () => {
|
||||
attempts += 1
|
||||
throw dupError('uq_users_email_norm', 'taken@example.com')
|
||||
})
|
||||
const out = await sso.provisionSsoPlayer(req, 'google', profile)
|
||||
// PROVISION_MAX_TRIES is 25. Retrying usernames cannot clear an EMAIL conflict,
|
||||
// so 25 attempts would be 24 pointless writes ending in a log line blaming
|
||||
// usernames for something they had nothing to do with.
|
||||
assert.equal(attempts, 1, 'must not retry a conflict no username change can resolve')
|
||||
assert.equal(out.error, 'email_in_use', 'and must say which conflict it was')
|
||||
assert.equal(out.user, undefined)
|
||||
})
|
||||
|
||||
test('a username conflict still retries the next candidate', async () => {
|
||||
let attempts = 0
|
||||
stub(users, 'createUser', async () => {
|
||||
attempts += 1
|
||||
if (attempts < 3) throw dupError('username', 'someone')
|
||||
return { id: 42, username: `someone${attempts}`, role: 'player' }
|
||||
})
|
||||
stub(userIdentities, 'link', async () => {})
|
||||
stub(activity, 'log', async () => {})
|
||||
const out = await sso.provisionSsoPlayer(req, 'google', profile)
|
||||
assert.equal(attempts, 3, 'the bounded username retry is unchanged')
|
||||
assert.equal(out.user.id, 42)
|
||||
assert.equal(out.error, undefined)
|
||||
})
|
||||
|
||||
test('exhausting username candidates reports a generic error, not an email one', async () => {
|
||||
stub(users, 'createUser', async () => {
|
||||
throw dupError('username', 'someone')
|
||||
})
|
||||
const out = await sso.provisionSsoPlayer(req, 'google', profile)
|
||||
assert.equal(out.error, 'error')
|
||||
assert.equal(out.user, undefined)
|
||||
})
|
||||
Reference in New Issue
Block a user