Skip to content
64 changes: 61 additions & 3 deletions modules/auth/controllers/auth.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,25 @@
* @param {Object} res - Express response object
* TODO: escape deprecated
*/
/**
* @desc Strip OAuth tokens from an additionalProvidersData map before serializing to client.
* Only the provider identity fields are safe to expose; access/refresh tokens must stay server-side.
* @param {Object|undefined} apd - raw additionalProvidersData
* @returns {Object|undefined} sanitized map with accessToken/refreshToken removed per provider
*/
const sanitizeAdditionalProvidersData = (apd) => {

Check warning on line 243 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#L243

Non-serializable expression must be wrapped with $(...)
if (!apd || typeof apd !== 'object') return undefined;
const sanitized = {};
for (const [prov, data] of Object.entries(apd)) {
if (data && typeof data === 'object') {
// eslint-disable-next-line no-unused-vars
const { accessToken, refreshToken, ...safe } = data;
sanitized[prov] = safe;

Check warning on line 250 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#L250

Generic Object Injection Sink
}
}
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
};

const token = async (req, res) => {
let user = null;
if (req.user) {
Expand All @@ -248,7 +267,7 @@
email: req.user.email,
lastName: req.user.lastName,
firstName: req.user.firstName,
additionalProvidersData: req.user.additionalProvidersData,
additionalProvidersData: sanitizeAdditionalProvidersData(req.user.additionalProvidersData),
emailVerified: req.user.emailVerified,
currentOrganization: req.user.currentOrganization,
lastLoginAt: req.user.lastLoginAt,
Expand Down Expand Up @@ -294,14 +313,28 @@
passport.authenticate(strategy)(req, res, next);
};

/**
* Known OAuth providers — used to validate the `provider` argument and `key` argument
* before constructing dynamic query paths, preventing prototype-pollution-style injections.
*/
const ALLOWED_PROVIDERS = new Set(['google', 'apple']);
const ALLOWED_PROVIDER_KEYS = new Set(['sub', 'id', 'email']);

/**
* @desc Endpoint to save oAuthProfile
* @param {Object} profil - OAuth user profile object
* @param {string} key - Provider key to lookup providerData
* @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
// Guard: validate provider and key against allowlists before using as dynamic object keys
if (!ALLOWED_PROVIDERS.has(provider)) {
throw new AppError('oAuth, unsupported provider', { code: 'VALIDATION_ERROR', details: { provider } });
}
if (!ALLOWED_PROVIDER_KEYS.has(key)) {
throw new AppError('oAuth, unsupported provider key', { code: 'VALIDATION_ERROR', details: { key } });
}
// 1. Primary identity: match on (provider, providerData[key]) — OAuth-first users
try {
const query = {};
query[`providerData.${key}`] = profil.providerData[key];
Expand All @@ -311,7 +344,31 @@
} 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 350 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#L350

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).
// Uses UserService.update('recover') to go through the service layer and Zod validation.
if (profil.email && profil.emailVerifiedByProvider) {
try {
const brutUser = await UserService.getBrut({ email: profil.email });
if (brutUser) {
const additionalProvidersData = { ...(brutUser.additionalProvidersData || {}), [provider]: profil.providerData };
return await UserService.update(brutUser, { additionalProvidersData, emailVerified: true }, 'recover');
}
} 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 +377,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
118 changes: 118 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,124 @@ 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[0].password,
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.emailVerified).toBe(true);
// Verify additionalProvidersData was persisted (getBrut bypasses the sanitize whitelist)
const brutLinked = await UserService.getBrut({ id: linked.id });
expect(brutLinked.additionalProvidersData?.google?.sub).toBe('google-link-sub-12345');

// Subsequent signin with the same Google sub should find the linked user via step 2
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', async () => {
const sharedEmail = 'oauthlink-unverified@test.com';
const localUser = await UserService.create({
firstName: 'Unverified',
lastName: 'Link',
email: sharedEmail,
password: credentials[0].password,
provider: 'local',
roles: ['user'],
});

// OAuth arrives for the SAME email but email_verified=false — must not link
const profil = {
firstName: 'Unverified',
lastName: 'Link',
email: sharedEmail,
avatar: '',
providerData: { sub: 'google-unverified-sub-999', email_verified: false },
emailVerifiedByProvider: false,
};
// Falls through to create branch → duplicate email → unique-index error
await expect(
AuthController.checkOAuthUserProfile(profil, 'sub', 'google'),
).rejects.toThrow();
// Local account must remain untouched — no OAuth data attached
const untouched = await UserService.getBrut({ email: sharedEmail });
expect(untouched).toBeDefined();
expect(untouched.additionalProvidersData?.google).toBeUndefined();

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

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[0].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,
};
// Must error — unverified email falls to create branch → duplicate email → AppError
await expect(
AuthController.checkOAuthUserProfile(profil, 'sub', 'google'),
).rejects.toMatchObject({ code: 'CONTROLLER_ERROR' });
// Verify the local account was NOT modified — exactly one user with this email, no OAuth data
const users = await UserService.search({ email: sharedEmail });
expect(users.length).toBe(1);
expect(users[0].additionalProvidersData).toBeUndefined();

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
2 changes: 1 addition & 1 deletion modules/users/config/users.development.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const config = {
],
update: ['firstName', 'lastName', 'bio', 'position', 'email', 'avatar', 'complementary'],
updateAdmin: ['firstName', 'lastName', 'bio', 'position', 'email', 'avatar', 'roles', 'complementary'],
recover: ['password', 'resetPasswordToken', 'resetPasswordExpires', 'emailVerified', 'emailVerificationToken', 'emailVerificationExpires'],
recover: ['password', 'resetPasswordToken', 'resetPasswordExpires', 'emailVerified', 'emailVerificationToken', 'emailVerificationExpires', 'additionalProvidersData'],
roles: ['user', 'admin'],
},
},
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