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