feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
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

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:
2026-08-29 01:53:50 -05:00
parent c2e4df5b3d
commit fbb4b0bd91
44 changed files with 3024 additions and 59 deletions

View 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)
})