Files
website/server/test/providers.test.js
wtclaude fbb4b0bd91
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
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>
2026-08-29 01:53:50 -05:00

104 lines
4.8 KiB
JavaScript

process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret'
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, after, beforeEach, 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 db = require('../src/utils/db')
after(() => db.close())
const realFetch = global.fetch
afterEach(() => {
global.fetch = realFetch
})
// Install a fetch stub that answers by URL substring.
function mockFetch(routes) {
global.fetch = async (url) => {
for (const [needle, payload] of Object.entries(routes)) {
if (String(url).includes(needle)) {
return { ok: true, status: 200, json: async () => payload, text: async () => JSON.stringify(payload) }
}
}
return { ok: false, status: 404, text: async () => 'not found' }
}
}
test('Google getAuthorizationUrl includes client_id, redirect_uri, scope, state, PKCE', () => {
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
const url = p.getAuthorizationUrl('the-state', { redirectUri: 'https://app/cb', codeChallenge: 'CHAL' })
assert.ok(url.startsWith('https://accounts.google.com/o/oauth2/v2/auth?'))
const q = new URL(url).searchParams
assert.equal(q.get('client_id'), 'gid')
assert.equal(q.get('redirect_uri'), 'https://app/cb')
assert.equal(q.get('response_type'), 'code')
assert.equal(q.get('scope'), 'openid email profile')
assert.equal(q.get('state'), 'the-state')
assert.equal(q.get('code_challenge'), 'CHAL')
assert.equal(q.get('code_challenge_method'), 'S256')
})
test('Google handleCallback exchanges code and normalizes the profile', async () => {
mockFetch({
'oauth2.googleapis.com/token': { access_token: 'AT' },
'openidconnect.googleapis.com/v1/userinfo': { sub: '11550', email: 'alice@example.com', name: 'Alice' },
})
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
const profile = await p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' })
// 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 () => {
const p = new DiscordProvider({ id: 'discord', clientId: 'did', clientSecret: 'dsecret' })
const url = p.getAuthorizationUrl('s', { redirectUri: 'https://app/cb' })
assert.ok(url.startsWith('https://discord.com/oauth2/authorize?'))
assert.equal(new URL(url).searchParams.get('scope'), 'identify email')
mockFetch({
'discord.com/api/oauth2/token': { access_token: 'AT' },
'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' })
// 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 () => {
const p = new GenericOidcProvider({
id: 'authentik', kind: 'oidc', clientId: 'cid', clientSecret: 'csec',
authorizeUrl: 'https://idp.example/authorize', tokenUrl: 'https://idp.example/token',
userinfoUrl: 'https://idp.example/userinfo', scopes: 'openid email',
})
const url = p.getAuthorizationUrl('s', { redirectUri: 'https://app/cb', codeChallenge: 'CH' })
assert.ok(url.startsWith('https://idp.example/authorize?'))
assert.equal(new URL(url).searchParams.get('scope'), 'openid email')
mockFetch({
'idp.example/token': { access_token: 'AT' },
'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' })
// 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 () => {
mockFetch({}) // everything 404s
const p = new GoogleProvider({ id: 'google', clientId: 'gid', clientSecret: 'gsecret' })
await assert.rejects(() => p.handleCallback({ code: 'C', redirectUri: 'https://app/cb', codeVerifier: 'V' }))
})