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>
141 lines
6.4 KiB
JavaScript
141 lines
6.4 KiB
JavaScript
// 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)
|
|
})
|