diff --git a/server/lib/oauthUsers.js b/server/lib/oauthUsers.js index 1e3252b..4bd9a02 100644 --- a/server/lib/oauthUsers.js +++ b/server/lib/oauthUsers.js @@ -41,6 +41,7 @@ function formatUserResponse(user) { async function linkOAuthProvider(userId, provider, providerId, opts = {}) { const pool = require('../db') const idColumn = provider === 'google' ? 'google_id' : 'github_id' + const providerEmail = opts.email?.toLowerCase() const taken = await pool.query( `SELECT id FROM users WHERE ${idColumn} = $1 AND id != $2`, @@ -53,7 +54,7 @@ async function linkOAuthProvider(userId, provider, providerId, opts = {}) { } const current = await pool.query( - `SELECT google_id, github_id FROM users WHERE id = $1`, + `SELECT email, google_id, github_id FROM users WHERE id = $1`, [userId] ) if (current.rows[0]?.[idColumn]) { @@ -70,7 +71,11 @@ async function linkOAuthProvider(userId, provider, providerId, opts = {}) { updates.push(`email = COALESCE(email, $${param++})`) values.push(opts.email) } - if (opts.emailVerified) { + const canVerifyStoredEmail = opts.email && opts.emailVerified && ( + !current.rows[0]?.email || + current.rows[0].email.toLowerCase() === providerEmail + ) + if (canVerifyStoredEmail) { updates.push(`email_verified = CASE WHEN $${param++} THEN true ELSE email_verified END`) values.push(true) } diff --git a/server/middleware/requireVerifiedEmail.js b/server/middleware/requireVerifiedEmail.js index ad0de07..8ae7816 100644 --- a/server/middleware/requireVerifiedEmail.js +++ b/server/middleware/requireVerifiedEmail.js @@ -1,14 +1,12 @@ const pool = require('../db') /** - * Blocks posting for local email/password users until email is verified. - * OAuth and ORCID-linked users may post without email verification. + * Blocks posting until the account's stored email identity is verified. */ async function requireVerifiedEmail(req, res, next) { try { const result = await pool.query( - `SELECT email_verified, google_id, github_id, orcid_id - FROM users WHERE id = $1`, + 'SELECT email_verified FROM users WHERE id = $1', [req.user.userId] ) const user = result.rows[0] @@ -16,10 +14,6 @@ async function requireVerifiedEmail(req, res, next) { return res.status(401).json({ error: 'Unauthorized' }) } - if (user.google_id || user.github_id || user.orcid_id) { - return next() - } - if (!user.email_verified) { return res.status(403).json({ error: 'Please verify your email before posting', diff --git a/server/tests/auth-verify.test.js b/server/tests/auth-verify.test.js index c612787..a8d8a02 100644 --- a/server/tests/auth-verify.test.js +++ b/server/tests/auth-verify.test.js @@ -70,6 +70,21 @@ describe('Email verification flow', () => { expect(res.body.code).toBe('EMAIL_UNVERIFIED') }) + it('still blocks posting when an unverified user links an OAuth provider', async () => { + await pool.query( + 'UPDATE users SET google_id = $1 WHERE id = $2', + [`google_verify_${ts}`, userId] + ) + + const res = await request(app) + .post(`/discussions/${discussionId}/comments`) + .set('Cookie', cookie) + .send({ body: 'OAuth link should not bypass email verification' }) + + expect(res.status).toBe(403) + expect(res.body.code).toBe('EMAIL_UNVERIFIED') + }) + it('allows posting after email verification', async () => { const rawToken = crypto.randomBytes(32).toString('hex') await pool.query( diff --git a/server/tests/oauth-users.test.js b/server/tests/oauth-users.test.js index ae58a24..5a00b81 100644 --- a/server/tests/oauth-users.test.js +++ b/server/tests/oauth-users.test.js @@ -4,6 +4,7 @@ jest.mock('../db', () => ({ const db = require('../db') const { findOrCreateOAuthUser } = require('../routes/google') +const { linkOAuthProvider } = require('../lib/oauthUsers') beforeEach(() => { db.query.mockReset() @@ -116,3 +117,58 @@ describe('findOrCreateOAuthUser', () => { expect(db.query).toHaveBeenCalledTimes(2) }) }) + +describe('linkOAuthProvider', () => { + it('does not verify an existing account email with a different provider email', async () => { + db.query + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [{ + email: 'local@example.com', + google_id: null, + github_id: null, + }], + }) + .mockResolvedValueOnce({ rows: [] }) + + await linkOAuthProvider('user-1', 'google', 'google-1', { + email: 'provider@example.com', + emailVerified: true, + }) + + expect(db.query).toHaveBeenCalledTimes(3) + expect(db.query.mock.calls[2][0]).not.toContain('email_verified') + expect(db.query.mock.calls[2][1]).toEqual([ + 'google-1', + 'provider@example.com', + 'user-1', + ]) + }) + + it('verifies the stored account email when the provider confirms the same email', async () => { + db.query + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [{ + email: 'local@example.com', + google_id: null, + github_id: null, + }], + }) + .mockResolvedValueOnce({ rows: [] }) + + await linkOAuthProvider('user-1', 'google', 'google-1', { + email: 'LOCAL@example.com', + emailVerified: true, + }) + + expect(db.query).toHaveBeenCalledTimes(3) + expect(db.query.mock.calls[2][0]).toContain('email_verified') + expect(db.query.mock.calls[2][1]).toEqual([ + 'google-1', + 'LOCAL@example.com', + true, + 'user-1', + ]) + }) +})