From 5d812c5e7460af086e0e1986aa6c14bb26f72890 Mon Sep 17 00:00:00 2001 From: joschonarth Date: Tue, 13 Oct 2026 20:14:59 -0300 Subject: [PATCH] fix: mitigate account pre-hijacking in Google sign-in --- .../sign-in-with-google.use-case.spec.ts | 70 +++++++++++++++++++ .../use-cases/sign-in-with-google.use-case.ts | 14 ++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/modules/user/use-cases/sign-in-with-google.use-case.spec.ts b/src/modules/user/use-cases/sign-in-with-google.use-case.spec.ts index a26405b..5c4d938 100644 --- a/src/modules/user/use-cases/sign-in-with-google.use-case.spec.ts +++ b/src/modules/user/use-cases/sign-in-with-google.use-case.spec.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { GoogleAuthProviderError } from '@/modules/user/errors/google-auth-provider.error' +import { InvalidCredentialsError } from '@/modules/user/errors/invalid-credentials.error' import { InvalidGoogleTokenError } from '@/modules/user/errors/invalid-google-token.error' import { type GoogleAuthProvider, @@ -8,6 +9,7 @@ import { import { InMemoryUsersRepository } from '@/modules/user/repositories/in-memory/in-memory-users.repository' import { makeUser } from '@/modules/user/tests/factories/make-user' +import { AuthenticateUseCase } from './authenticate.use-case' import { SignInWithGoogleUseCase } from './sign-in-with-google.use-case' let usersRepository: InMemoryUsersRepository @@ -180,6 +182,74 @@ describe('Sign In With Google Use Case', () => { expect(signedInUser.avatarUrl).toBe('https://example.com/avatar.png') }) + it('should remove an existing password when linking a verified Google account', async () => { + const user = await makeUser(usersRepository, { + email: 'johndoe@example.com', + name: 'John Doe', + avatarUrl: 'https://example.com/avatar.png', + rawPassword: 'old-password', + }) + vi.mocked(googleAuthProvider.getUserInfo).mockResolvedValue({ + email: 'johndoe@example.com', + verified_email: true, + name: 'John Doe', + picture: 'https://example.com/avatar.png', + }) + const update = vi.spyOn(usersRepository, 'update') + + const { user: linkedUser } = await sut.execute({ + token: 'google-access-token', + }) + + expect(linkedUser.password).toBeNull() + expect(update).toHaveBeenCalledWith(user.id, { password: null }) + }) + + it('should not update an unchanged Google-only account', async () => { + await makeUser(usersRepository, { + email: 'johndoe@example.com', + name: 'John Doe', + avatarUrl: 'https://example.com/avatar.png', + password: null, + }) + vi.mocked(googleAuthProvider.getUserInfo).mockResolvedValue({ + email: 'johndoe@example.com', + verified_email: true, + name: 'John Doe', + picture: 'https://example.com/avatar.png', + }) + const update = vi.spyOn(usersRepository, 'update') + + await sut.execute({ token: 'google-access-token' }) + + expect(update).not.toHaveBeenCalled() + }) + + it('should reject the old password after linking a verified Google account', async () => { + await makeUser(usersRepository, { + email: 'johndoe@example.com', + name: 'John Doe', + avatarUrl: 'https://example.com/avatar.png', + rawPassword: 'old-password', + }) + vi.mocked(googleAuthProvider.getUserInfo).mockResolvedValue({ + email: 'johndoe@example.com', + verified_email: true, + name: 'John Doe', + picture: 'https://example.com/avatar.png', + }) + + await sut.execute({ token: 'google-access-token' }) + + const authenticate = new AuthenticateUseCase(usersRepository) + await expect( + authenticate.execute({ + email: 'johndoe@example.com', + password: 'old-password', + }) + ).rejects.toBeInstanceOf(InvalidCredentialsError) + }) + it('should fill an empty name from Google', async () => { await makeUser(usersRepository, { email: 'johndoe@example.com', diff --git a/src/modules/user/use-cases/sign-in-with-google.use-case.ts b/src/modules/user/use-cases/sign-in-with-google.use-case.ts index 85eccd9..c72c8cc 100644 --- a/src/modules/user/use-cases/sign-in-with-google.use-case.ts +++ b/src/modules/user/use-cases/sign-in-with-google.use-case.ts @@ -64,13 +64,17 @@ export class SignInWithGoogleUseCase { } } - const needsUpdate = - (!user.name?.trim() && name) || (!user.avatarUrl && picture) + const updateData = { + ...(!user.name?.trim() && name && { name }), + ...(!user.avatarUrl && picture && { avatarUrl: picture }), + ...(user.password !== null && { password: null }), + } - if (needsUpdate) { + if (Object.keys(updateData).length > 0) { + // Temporary pre-hijacking mitigation: a verified Google email proves mailbox ownership. + // Remove the pre-registered password until the app has its own email verification flow. user = await this.usersRepository.update(user.id, { - ...(!user.name?.trim() && name && { name }), - ...(!user.avatarUrl && picture && { avatarUrl: picture }), + ...updateData, }) }