Skip to content

Commit 383f555

Browse files
fix(auth): require verified local account for OAuth email link (#3504) (#3511)
* fix(auth): require verified local account for OAuth email link An unverified local signup squatting a victim's email could be silently annexed by a later OAuth signin (Google/Apple) because the link-by-email branch only checked `profil.emailVerifiedByProvider`. The pre-existing local account's own `emailVerified` flag was ignored, so the OAuth signin inherited the unverified account, its password, and any state attached. Tighten the gate so the atomic link fires only when both sides vouch for the email: OAuth provider verified AND local account `emailVerified: true`. When the local account exists but is unverified, reject with a clear `VALIDATION_ERROR` instead of falling through to account creation (which would later throw on the unique-email index with a less actionable error). - `UserRepository.linkProviderByEmail` filter now includes `emailVerified: true`; dropped the redundant `$set` of the same field since the filter already guarantees it. - `AuthController.checkOAuthUserProfile` branch 3 follows up a null link with a `findByEmail` to detect the unverified-squatter case and throw `AppError('oAuth, cannot link to unverified local account', ...)`. - Integration tests: updated the "link succeeds" case to mark the local account verified; added a new squatter-rejection test asserting the local doc is unchanged (no additionalProvidersData, emailVerified still false, providerData still null). Closes #3504 * fix(auth): clarify branch-3 race behaviour in link-gate comment Self-review follow-up: the prior comment didn't explain what happens if `findByEmail` finds an already-verified user (the rare race where verification completed between the atomic findOneAndUpdate and the follow-up lookup). Document that falling through to branch 4 is the intended behaviour — branch 4 will fail on the unique-email index, and an OAuth retry will hit branch 3 and link cleanly. * test(auth): mark signup-gate branch-3 seed as emailVerified PR #3503 landed a new test for branch 3 that pre-dates this gate change. It creates a local user then expects the OAuth signin to link — but with the new gate the link now requires local emailVerified: true. Seed the local user as verified (same pattern as the existing link-succeeds test) so the test continues to exercise the happy path post-rebase.
1 parent 9a1a513 commit 383f555

3 files changed

Lines changed: 92 additions & 9 deletions

File tree

modules/auth/controllers/auth.controller.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -358,15 +358,39 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
358358
} catch (err) {
359359
throw new AppError('oAuth, find linked user failed', { code: 'SERVICE_ERROR', details: err });
360360
}
361-
// 3. Link on verified email: if a local user exists with the same email and the OAuth
362-
// provider vouches for it, attach providerData under additionalProvidersData.{provider}
363-
// without overwriting user.provider (keeps password reset + local login intact).
364-
// Atomic findOneAndUpdate avoids TOCTOU races between concurrent OAuth callbacks.
361+
// 3. Link on verified email: if a local user exists with the same email AND is
362+
// already emailVerified locally AND the OAuth provider vouches for the email,
363+
// attach providerData under additionalProvidersData.{provider} without
364+
// overwriting user.provider (keeps password reset + local login intact).
365+
// Atomic findOneAndUpdate (filter includes emailVerified: true) avoids TOCTOU
366+
// races and prevents an unverified-squatter local account from being annexed
367+
// by a later OAuth signin (issue #3504). If a matching email exists but is
368+
// not locally verified, we reject with VALIDATION_ERROR rather than fall
369+
// through to branch 4 (which would later fail on the unique-email index).
365370
if (profil.email && profil.emailVerifiedByProvider) {
366371
try {
367372
const linked = await UserService.linkProviderByEmail(profil.email, provider, profil.providerData);
368373
if (linked) return linked;
374+
// Link returned null → either no local user with this email, or the local
375+
// user exists but is not emailVerified. Disambiguate so we can reject the
376+
// squatter case explicitly instead of falling through to branch 4 (which
377+
// would later fail on the unique-email index with a less actionable error).
378+
const existing = await UserService.findByEmail(profil.email);
379+
if (existing && !existing.emailVerified) {
380+
throw new AppError('oAuth, cannot link to unverified local account', {
381+
code: 'VALIDATION_ERROR',
382+
details: {
383+
message: 'A pending account with this email is not verified. Verify the original signup first or contact support.',
384+
},
385+
});
386+
}
387+
// If `existing` is emailVerified here, a rare race between the atomic
388+
// findOneAndUpdate and this findByEmail let verification complete in
389+
// between. Falling through is safe: branch 4 will fail on the unique
390+
// email index, the OAuth client will see the error, and a retry will
391+
// hit the now-linkable state via branch 3.
369392
} catch (err) {
393+
if (err instanceof AppError) throw err;
370394
throw new AppError('oAuth, link to existing user failed', { code: 'SERVICE_ERROR', details: err });
371395
}
372396
}

modules/auth/tests/auth.integration.tests.js

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -748,8 +748,9 @@ describe('Auth integration tests:', () => {
748748
} catch (_) { /* cleanup – ignore errors */ }
749749
});
750750

751-
test('should link OAuth signin to existing local user when provider verifies email', async () => {
752-
// Seed a local user with a password (provider=local), email not yet linked via OAuth
751+
test('should link OAuth signin to existing local user when both local emailVerified and provider verify', async () => {
752+
// Seed a local user with a password (provider=local) that has ALREADY verified its email
753+
// — the branch-3 link-on-verified-email gate requires both sides to vouch (issue #3504).
753754
const localEmail = 'oauthlink-local@test.com';
754755
const localUser = await UserService.create({
755756
firstName: 'Local',
@@ -759,6 +760,9 @@ describe('Auth integration tests:', () => {
759760
provider: 'local',
760761
roles: ['user'],
761762
});
763+
// Mark the local account as email-verified (done normally by the verification flow).
764+
const brutLocal = await UserService.getBrut({ email: localEmail });
765+
await UserService.update(brutLocal, { emailVerified: true }, 'recover');
762766

763767
// OAuth signin arrives with matching email + provider-verified flag
764768
const profil = {
@@ -786,6 +790,52 @@ describe('Auth integration tests:', () => {
786790
try { await UserService.remove(linked); } catch (_) { /* cleanup */ }
787791
});
788792

793+
test('should reject link when local account is NOT emailVerified even if OAuth provider verified (#3504)', async () => {
794+
// Squatter scenario (issue #3504): someone signed up locally with victim's email
795+
// but never verified it. A later OAuth signin by the real owner must NOT silently
796+
// annex the unverified local account.
797+
const squatterEmail = 'oauthlink-squatter@test.com';
798+
const localUser = await UserService.create({
799+
firstName: 'Squatter',
800+
lastName: 'Pending',
801+
email: squatterEmail,
802+
password: credentials[0].password,
803+
provider: 'local',
804+
roles: ['user'],
805+
});
806+
// Sanity: freshly created local user is not emailVerified by default.
807+
const brutBefore = await UserService.getBrut({ email: squatterEmail });
808+
expect(brutBefore.emailVerified).toBe(false);
809+
expect(brutBefore.additionalProvidersData?.google).toBeUndefined();
810+
expect(brutBefore.providerData == null).toBe(true);
811+
812+
const profil = {
813+
firstName: 'Real',
814+
lastName: 'Owner',
815+
email: squatterEmail,
816+
avatar: '',
817+
providerData: { sub: 'google-owner-sub-3504', email_verified: true },
818+
emailVerifiedByProvider: true,
819+
};
820+
821+
await expect(
822+
AuthController.checkOAuthUserProfile(profil, 'sub', 'google'),
823+
).rejects.toMatchObject({
824+
code: 'VALIDATION_ERROR',
825+
message: 'oAuth, cannot link to unverified local account',
826+
});
827+
828+
// The unverified local account must be untouched — no silent annexation.
829+
const users = await UserService.search({ email: squatterEmail });
830+
expect(users.length).toBe(1);
831+
const brutAfter = await UserService.getBrut({ email: squatterEmail });
832+
expect(brutAfter.emailVerified).toBe(false);
833+
expect(brutAfter.additionalProvidersData?.google).toBeUndefined();
834+
expect(brutAfter.providerData == null).toBe(true);
835+
836+
try { await UserService.remove(localUser); } catch (_) { /* cleanup */ }
837+
});
838+
789839
test('should NOT link when OAuth provider did not verify the email', async () => {
790840
const sharedEmail = 'oauthlink-unverified@test.com';
791841
const localUser = await UserService.create({
@@ -995,6 +1045,10 @@ describe('Auth integration tests:', () => {
9951045
provider: 'local',
9961046
roles: ['user'],
9971047
});
1048+
// Mark the local account as email-verified — branch 3 link-gate now
1049+
// requires both OAuth provider AND local emailVerified (issue #3504).
1050+
const brutSeed = await UserService.getBrut({ email });
1051+
await UserService.update(brutSeed, { emailVerified: true }, 'recover');
9981052

9991053
config.sign.up = false;
10001054
const profil = {

modules/users/repositories/users.repository.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,15 +194,20 @@ const updateMany = (filter, data) => User.updateMany(filter, data, { runValidato
194194
/**
195195
* @desc Atomically attach an OAuth provider to an existing user matched by email.
196196
* Uses findOneAndUpdate to avoid TOCTOU races between concurrent OAuth callbacks.
197+
* Filter requires `emailVerified: true` so an unverified-squatter local signup
198+
* cannot be silently annexed by a later OAuth signin (issue #3504).
197199
* @param {string} email - The email to match
198200
* @param {string} provider - The OAuth provider key (e.g. 'google', 'apple')
199201
* @param {Object} providerData - The provider's identity data to store
200-
* @returns {Promise<Object|null>} Updated user document or null if no match
202+
* @returns {Promise<Object|null>} Updated user document, or null when no match
203+
* OR when a match exists but is not email-verified — the caller is expected
204+
* to follow up with findByEmail to distinguish the two cases if it needs to
205+
* return a specific error.
201206
*/
202207
const linkProviderByEmail = (email, provider, providerData) =>
203208
User.findOneAndUpdate(
204-
{ email },
205-
{ $set: { [`additionalProvidersData.${provider}`]: providerData, emailVerified: true } },
209+
{ email, emailVerified: true },
210+
{ $set: { [`additionalProvidersData.${provider}`]: providerData } },
206211
{ returnDocument: 'after', runValidators: true },
207212
).exec();
208213

0 commit comments

Comments
 (0)