diff --git a/backend/src/routes/auth/__tests__/oauthHandler.test.ts b/backend/src/routes/auth/__tests__/oauthHandler.test.ts new file mode 100644 index 00000000..d3d9f1ee --- /dev/null +++ b/backend/src/routes/auth/__tests__/oauthHandler.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../config.js', () => ({ + env: { + googleClientId: 'cid', + googleClientSecret: 'sec', + googleCallbackUrl: 'http://localhost/callback', + }, +})); + +vi.mock('../../../logging/logger.js', () => ({ + appLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() }, +})); + +const hashPassword = vi.fn().mockResolvedValue('hash'); +const generatePasswordResetToken = vi.fn().mockReturnValue('rand'); +const generateTokens = vi.fn().mockReturnValue({ accessToken: 'a', refreshToken: 'r' }); +const hashRefreshToken = vi.fn().mockReturnValue('rh'); +const refreshTokenExpiryMs = vi.fn().mockReturnValue(1000); + +vi.mock('../../../services/authService.js', () => ({ + authService: { + hashPassword, + generatePasswordResetToken, + generateTokens, + hashRefreshToken, + refreshTokenExpiryMs, + }, +})); + +import { handleGoogleCallback } from '../oauthHandler.js'; + +function mockRes() { + const res: any = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res; +} + +function mockRepo(overrides: Record = {}) { + return { + findByEmail: vi.fn().mockResolvedValue(null), + create: vi.fn(), + markEmailVerified: vi.fn(), + findById: vi.fn(), + updateLastLogin: vi.fn(), + toSafeUser: vi.fn((u) => u), + storeRefreshToken: vi.fn(), + ...overrides, + } as any; +} + +describe('handleGoogleCallback', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => { + if (String(url).includes('oauth2.googleapis.com/token')) { + return { ok: true, json: async () => ({ access_token: 'tok', id_token: 'id' }) } as any; + } + return { + ok: true, + json: async () => (globalThis as any).__googleUser, + } as any; + }) + ); + }); + + it('rejects unverified Google emails', async () => { + (globalThis as any).__googleUser = { + id: 'g1', + email: 'a@x.com', + name: 'A', + verified_email: false, + }; + const res = mockRes(); + await handleGoogleCallback({ body: { code: 'c' }, ip: '1', get: () => 'ua' } as any, res, mockRepo()); + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('blocks silent merge into unverified password account', async () => { + (globalThis as any).__googleUser = { + id: 'g1', + email: 'a@x.com', + name: 'A', + verified_email: true, + }; + const existing = { user_id: 'u1', email: 'a@x.com', email_verified: false }; + const res = mockRes(); + await handleGoogleCallback( + { body: { code: 'c' }, ip: '1', get: () => 'ua' } as any, + res, + mockRepo({ findByEmail: vi.fn().mockResolvedValue(existing) }) + ); + expect(res.status).toHaveBeenCalledWith(409); + }); + + it('logs into verified existing account', async () => { + (globalThis as any).__googleUser = { + id: 'g1', + email: 'a@x.com', + name: 'A', + verified_email: true, + }; + const existing = { user_id: 'u1', email: 'a@x.com', email_verified: true }; + const repo = mockRepo({ + findByEmail: vi.fn().mockResolvedValue(existing), + toSafeUser: vi.fn().mockReturnValue(existing), + }); + const res = mockRes(); + await handleGoogleCallback({ body: { code: 'c' }, ip: '1', get: () => 'ua' } as any, res, repo); + expect(repo.updateLastLogin).toHaveBeenCalledWith('u1'); + expect(res.json).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalledWith(409); + expect(res.status).not.toHaveBeenCalledWith(403); + }); +}); diff --git a/backend/src/routes/auth/oauthHandler.ts b/backend/src/routes/auth/oauthHandler.ts index 71aa2922..e2844c47 100644 --- a/backend/src/routes/auth/oauthHandler.ts +++ b/backend/src/routes/auth/oauthHandler.ts @@ -85,12 +85,38 @@ export async function handleGoogleCallback( verified_email?: boolean; }; + // Require Google-verified email to prevent account takeover via unverified aliases. + if (!googleUser.verified_email) { + appLogger.warn(`[auth] Google OAuth rejected unverified email ${googleUser.email}`); + return res.status(403).json({ + error: 'Google account email is not verified. Verify the email with Google and try again.' + }); + } + // Check if user exists by email const existingUser = await userRepository.findByEmail(googleUser.email); let safeUser: SafeUser; if (existingUser) { - // User exists, update last login + // Do not silently merge OAuth into a password-registered account without + // proof of control. Password accounts keep a password_hash; OAuth-only + // accounts were created with a random unusable password but are marked + // email_verified at creation. Require an already-verified email on the + // local user before linking (login), otherwise ask them to sign in with + // password first or contact support to link. + const isEmailVerified = Boolean( + (existingUser as { email_verified?: boolean; emailVerified?: boolean }).email_verified + ?? (existingUser as { emailVerified?: boolean }).emailVerified + ); + if (!isEmailVerified) { + appLogger.warn( + `[auth] Google OAuth blocked silent merge into unverified password account ${googleUser.email}` + ); + return res.status(409).json({ + error: + 'An account with this email already exists. Sign in with your password, then link Google from settings.' + }); + } await userRepository.updateLastLogin(existingUser.user_id); safeUser = userRepository.toSafeUser(existingUser); } else {