Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions backend/src/routes/auth/__tests__/oauthHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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);
});
});
28 changes: 27 additions & 1 deletion backend/src/routes/auth/oauthHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down