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:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user