Skip to content
6 changes: 6 additions & 0 deletions lib/services/tests/analytics.identify.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ describe('Analytics identify on auth events:', () => {
remove: jest.fn(),
},
}));
jest.unstable_mockModule('../../../modules/users/repositories/users.repository.js', () => ({
default: { update: jest.fn().mockResolvedValue(mockUser) },
}));

jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
default: {
Expand Down Expand Up @@ -197,6 +200,9 @@ describe('Analytics identify on auth events:', () => {
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
default: { getBrut: jest.fn(), update: jest.fn() },
}));
jest.unstable_mockModule('../../../modules/users/repositories/users.repository.js', () => ({
default: { update: jest.fn() },
}));

jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
default: { handleSignupOrganization: jest.fn() },
Expand Down
32 changes: 30 additions & 2 deletions modules/auth/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import jwt from 'jsonwebtoken';

import UserService from '../../users/services/users.service.js';
import UserRepository from '../../users/repositories/users.repository.js';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
import config from '../../../config/index.js';
import model from '../../../lib/middlewares/model.js';
import mails from '../../../lib/helpers/mailer/index.js';
Expand Down Expand Up @@ -301,7 +302,7 @@
* @param {string} provider - OAuth provider name
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
*/
const checkOAuthUserProfile = async (profil, key, provider) => {
// check if user exist
// 1. Primary identity: match on (provider, providerData[key]) — OAuth-first users
try {
const query = {};
query[`providerData.${key}`] = profil.providerData[key];
Expand All @@ -311,7 +312,33 @@
} catch (err) {
throw new AppError('oAuth, find user failed', { code: 'SERVICE_ERROR', details: err });
}
// if no, generate
// 2. Linked identity: match on additionalProvidersData[provider][key] — locals already linked
try {
const query = {};
query[`additionalProvidersData.${provider}.${key}`] = profil.providerData[key];

Check warning on line 318 in modules/auth/controllers/auth.controller.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

modules/auth/controllers/auth.controller.js#L318

Generic Object Injection Sink
Comment thread
PierreBrisorgueil marked this conversation as resolved.
const search = await UserService.search(query);
if (search.length === 1) return search[0];
} catch (err) {
throw new AppError('oAuth, find linked user failed', { code: 'SERVICE_ERROR', details: err });
}
// 3. Link on verified email: if a local user exists with the same email and the OAuth
// provider vouches for it, attach providerData under additionalProvidersData.{provider}
// without overwriting user.provider (keeps password reset + local login intact).
if (profil.email && profil.emailVerifiedByProvider) {
try {
const existing = await UserService.search({ email: profil.email });
if (existing.length === 1) {
const user = existing[0];
user.additionalProvidersData = user.additionalProvidersData || {};
user.additionalProvidersData[provider] = profil.providerData;

Check warning on line 333 in modules/auth/controllers/auth.controller.js

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

modules/auth/controllers/auth.controller.js#L333

Generic Object Injection Sink
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
user.emailVerified = true;
return await UserRepository.update(user);
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
}
} catch (err) {
throw new AppError('oAuth, link to existing user failed', { code: 'SERVICE_ERROR', details: err });
}
}
Comment thread
PierreBrisorgueil marked this conversation as resolved.
// 4. No match → create new user
try {
const user = {
firstName: profil.firstName,
Expand All @@ -320,6 +347,7 @@
avatar: profil.avatar || '',
provider,
providerData: profil.providerData || null,
emailVerified: !!profil.emailVerifiedByProvider,
};
const result = model.getResultFromZod(user, UsersSchema.User);
// check error
Expand Down
1 change: 1 addition & 0 deletions modules/auth/strategies/local/apple.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const prepare = async (req, accessToken, refreshToken, decodedIdToken, profile,
avatar: null,
provider: 'apple',
providerData,
emailVerifiedByProvider: decodedIdToken.email_verified === true || decodedIdToken.email_verified === 'true',
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.
// Save the user OAuth profile
try {
Expand Down
1 change: 1 addition & 0 deletions modules/auth/strategies/local/google.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const prepare = async (accessToken, refreshToken, profile, cb) => {
avatar: providerData.picture ? providerData.picture : undefined,
provider: 'google',
providerData,
emailVerifiedByProvider: providerData.email_verified === true,
};
Comment thread
PierreBrisorgueil marked this conversation as resolved.
// Save the user OAuth profile
try {
Expand Down
108 changes: 108 additions & 0 deletions modules/auth/tests/auth.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,114 @@ describe('Auth integration tests:', () => {
} catch (_) { /* cleanup – ignore errors */ }
});

test('should link OAuth signin to existing local user when provider verifies email', async () => {
// Seed a local user with a password (provider=local), email not yet linked via OAuth
const localEmail = 'oauthlink-local@test.com';
const localUser = await UserService.create({
firstName: 'Local',
lastName: 'Link',
email: localEmail,
password: credentials.password,
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
provider: 'local',
roles: ['user'],
});

// OAuth signin arrives with matching email + provider-verified flag
const profil = {
firstName: 'Local',
lastName: 'Link',
email: localEmail,
avatar: '',
providerData: { sub: 'google-link-sub-12345', email_verified: true },
emailVerifiedByProvider: true,
};
const linked = await AuthController.checkOAuthUserProfile(profil, 'sub', 'google');

expect(linked).toBeDefined();
expect(linked.id).toBe(localUser.id);
expect(linked.provider).toBe('local'); // provider kept so password reset still works
expect(linked.additionalProvidersData.google.sub).toBe('google-link-sub-12345');
expect(linked.emailVerified).toBe(true);

// Subsequent signin with the same Google sub should find the linked user
const second = await AuthController.checkOAuthUserProfile(profil, 'sub', 'google');
expect(second.id).toBe(localUser.id);

try { await UserService.remove(linked); } catch (_) { /* cleanup */ }
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('should NOT link when OAuth provider did not verify the email (create new user instead)', async () => {
const sharedEmail = 'oauthlink-unverified@test.com';
Comment thread
PierreBrisorgueil marked this conversation as resolved.
Outdated
const localUser = await UserService.create({
firstName: 'Unverified',
lastName: 'Link',
email: sharedEmail,
password: credentials.password,
provider: 'local',
roles: ['user'],
});

const profil = {
// Different email to avoid Mongo unique collision on the fallback create branch
firstName: 'Other',
lastName: 'User',
email: 'oauthlink-different@test.com',
avatar: '',
providerData: { sub: 'google-unverified-sub-999', email_verified: false },
emailVerifiedByProvider: false,
};
const user = await AuthController.checkOAuthUserProfile(profil, 'sub', 'google');
expect(user.email).toBe('oauthlink-different@test.com');
expect(user.provider).toBe('google');
expect(user.additionalProvidersData).toBeUndefined();

try { await UserService.remove(localUser); } catch (_) { /* cleanup */ }
try { await UserService.remove(user); } catch (_) { /* cleanup */ }
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

test('should reject link when local email matches but OAuth provider did not verify (no takeover)', async () => {
const sharedEmail = 'oauthlink-takeover@test.com';
const localUser = await UserService.create({
firstName: 'Victim',
lastName: 'User',
Comment thread
PierreBrisorgueil marked this conversation as resolved.
email: sharedEmail,
password: credentials.password,
provider: 'local',
roles: ['user'],
});

// Attacker tries OAuth with same email but provider says email_verified=false
const profil = {
firstName: 'Attacker',
lastName: 'User',
email: sharedEmail,
avatar: '',
providerData: { sub: 'google-attacker-sub-42', email_verified: false },
emailVerifiedByProvider: false,
};
await expect(
AuthController.checkOAuthUserProfile(profil, 'sub', 'google'),
).rejects.toThrow(); // falls to create branch → duplicate email → error

try { await UserService.remove(localUser); } catch (_) { /* cleanup */ }
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('should set emailVerified=true when creating a fresh OAuth user with verified email', async () => {
const profil = {
firstName: 'Fresh',
lastName: 'OAuth',
email: 'oauth-fresh@test.com',
avatar: '',
providerData: { sub: 'google-fresh-sub-55', email_verified: true },
emailVerifiedByProvider: true,
};
const created = await AuthController.checkOAuthUserProfile(profil, 'sub', 'google');
expect(created.emailVerified).toBe(true);
expect(created.provider).toBe('google');

try { await UserService.remove(created); } catch (_) { /* cleanup */ }
});

afterAll(async () => {
for (const u of oauthUsers) {
try {
Expand Down
3 changes: 3 additions & 0 deletions modules/auth/tests/auth.silent.catch.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ describe('auth.controller silent-catch error logging:', () => {
remove: jest.fn(),
},
}));
jest.unstable_mockModule('../../../modules/users/repositories/users.repository.js', () => ({
default: { update: jest.fn().mockResolvedValue({}) },
}));

jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
default: {
Expand Down
1 change: 1 addition & 0 deletions modules/users/models/users.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const User = z.object({
/* Provider */
provider: z.string().optional(),
providerData: z.record(z.string(), z.unknown()).optional(),
additionalProvidersData: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/* Password */
password: z.string()
.max(config.zxcvbn.maxSize)
Expand Down
Loading