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:
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