feat(auth): link OAuth signin to existing user by verified email#3497
feat(auth): link OAuth signin to existing user by verified email#3497PierreBrisorgueil merged 6 commits intomasterfrom
Conversation
- checkOAuthUserProfile lookup order: (provider, sub) → additional ProvidersData[provider][key] → email + provider-verified → create - Store linked providers under additionalProvidersData, keep user.provider intact (password reset + local login still work) - Require emailVerifiedByProvider=true before linking (prevents takeover via unverified OIDC claims) - Set emailVerified=true on fresh OAuth create when provider vouches for the email (closes #3494) - Pass email_verified through google + apple strategies
|
Warning Rate limit exceeded
Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 15 minutes and 47 seconds. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (8)
WalkthroughThe changes implement OAuth account linking functionality. When authenticating via OAuth (Google/Apple), the system now searches for existing users by email and links their OAuth identity if the email is verified, preventing account takeover while improving UX. New OAuth users are created with Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant AuthCtrl as AuthController
participant UserRepo as UserRepository
participant UserSvc as UserService
participant DB as Database
Client->>AuthCtrl: checkOAuthUserProfile(profile)
Note over AuthCtrl: profile includes emailVerifiedByProvider
AuthCtrl->>UserRepo: findOne(provider, providerData[key])
UserRepo->>DB: query by (provider, key)
DB-->>UserRepo: user or null
UserRepo-->>AuthCtrl: existing user?
alt User found by provider
AuthCtrl-->>Client: return user
else User not found
AuthCtrl->>UserRepo: findOne by additionalProvidersData
UserRepo->>DB: query additionalProvidersData
DB-->>UserRepo: user or null
UserRepo-->>AuthCtrl: linked user?
alt User found via additionalProvidersData
AuthCtrl-->>Client: return user
else User not found
alt emailVerifiedByProvider && email exists
AuthCtrl->>UserRepo: findOne by email
UserRepo->>DB: query by email
DB-->>UserRepo: local user found
UserRepo-->>AuthCtrl: user or null
alt Local user found
alt local user.emailVerified
AuthCtrl->>UserRepo: update(attach OAuth data)
UserRepo->>DB: update additionalProvidersData
DB-->>UserRepo: updated user
UserRepo-->>AuthCtrl: linked user
AuthCtrl-->>Client: return linked user
else local user unverified
AuthCtrl-->>Client: reject (security: prevent takeover)
end
else No local user by email
AuthCtrl->>UserSvc: create(emailVerified=true)
UserSvc->>DB: insert new user
DB-->>UserSvc: created user
UserSvc-->>AuthCtrl: new user
AuthCtrl-->>Client: return new user
end
else No verified email
AuthCtrl->>UserSvc: create(emailVerified=false)
UserSvc->>DB: insert new user
DB-->>UserSvc: created user
UserSvc-->>AuthCtrl: new user
AuthCtrl-->>Client: return new user
end
end
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 3 high |
| Security | 2 high |
🟢 Metrics 24 complexity · 0 duplication
Metric Results Complexity 24 Duplication 0
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Pull Request Overview
This PR successfully implements OAuth account linking by verified email, ensuring users with existing local accounts can sign in via Google or Apple without duplication. All primary acceptance criteria and required test scenarios are addressed.
However, the implementation is currently not up to standards. Critical security issues were identified in modules/auth/controllers/auth.controller.js related to dynamic object property access and injection risks. Additionally, there is a layering violation where the controller interacts directly with UserRepository instead of UserService, which could skip essential validation and hooks. High complexity growth was also noted in the integration tests.
About this PR
- The new authentication logic introduces object injection vulnerabilities by using unsanitized variables to access and modify user data. Furthermore, bypassing the Service layer for database updates breaks architectural consistency and may lead to issues with data validation or change detection in Mongoose.
Test suggestions
- Link OAuth identity to existing local user with matching verified email
- Prevent account linking and create a new user if provider email is not verified
- Prevent account takeover when provider email is unverified and matches an existing local user
- Automatically verify email for new OAuth signups when provider vouches for verification
- Find and return a user who was previously linked via additionalProvidersData
🗒️ Improve review quality by adding custom instructions
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #3497 +/- ##
==========================================
+ Coverage 85.99% 86.24% +0.24%
==========================================
Files 116 116
Lines 2957 2987 +30
Branches 829 838 +9
==========================================
+ Hits 2543 2576 +33
Misses 328 328
+ Partials 86 83 -3 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
modules/auth/controllers/auth.controller.js (1)
298-304: 🧹 Nitpick | 🔵 TrivialUpdate JSDoc to document the new async return shape.
checkOAuthUserProfilewas modified and is async but has no@returns. The linking branch can now return a mutated existing user, the create branch returns a freshly created one, and the function throwsAppErrors for each distinct failure mode — worth documenting.As per coding guidelines: "Every new or modified function must have a JSDoc header: one-line description,
@paramfor each argument,@returnsfor any non-void return value (always include@returnsfor async functions to document the resolved value)".📝 Suggested JSDoc
/** - * `@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 + * `@desc` Resolve or create a user for an OAuth sign-in. Resolution order: + * (1) match on (provider, providerData[key]); (2) match on additionalProvidersData[provider][key]; + * (3) if provider vouches for email, link providerData onto the existing local user; + * (4) otherwise create a fresh OAuth user. + * `@param` {Object} profil - OAuth profile (firstName, lastName, email, avatar, providerData, emailVerifiedByProvider) + * `@param` {string} key - Provider key used to index providerData (e.g. 'sub', 'id') + * `@param` {string} provider - OAuth provider name (e.g. 'google', 'apple') + * `@returns` {Promise<Object>} Resolved, linked, or newly created user document */🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@modules/auth/controllers/auth.controller.js` around lines 298 - 304, The JSDoc for async function checkOAuthUserProfile is missing a `@returns` and must be updated: add a one-line description (if changed), keep `@param` entries for profil, key, provider, and add an `@returns` describing the Promise resolution shape (either a mutated existing User object when linking or a newly created User object when creating), and add an `@throws` (or `@throws` {AppError}) line documenting the distinct AppError failure modes the function can throw; reference the function name checkOAuthUserProfile and ensure the JSDoc reflects the async return type and error conditions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@modules/auth/controllers/auth.controller.js`:
- Around line 327-340: The current search → mutate → update sequence
(UserService.search followed by UserRepository.update) creates a TOCTOU race
where concurrent OAuth callbacks can overwrite each other's
additionalProvidersData; replace it with a single atomic conditional update: add
or use an atomic repository operation (e.g., UserRepository.findOneAndUpdate or
a new UserRepository.linkProviderAtomic) that matches the user by email and a
linkable flag (e.g., { email: profil.email, emailVerified: true }) and sets
additionalProvidersData.[provider] = profil.providerData and emailVerified =
true in one step, returning the updated user or null if no match so you avoid
the race and only link when the user is still linkable.
- Line 9: Controller is directly importing and calling UserRepository (import
UserRepository and the repository call currently in the linking branch),
violating layering; remove the UserRepository import and repository call and
instead route the operation through the service layer by calling a UserService
method (e.g., add/introduce a UserService.linkOAuthProvider(user, provider,
providerData) or reuse UserService.update(brutUser, patch, 'recover') used
elsewhere). Ensure the controller imports UserService (not the repository), that
the service performs Zod validation with UsersSchema.User before writing, and
update the controller to pass the user, provider identifier and provider payload
into that service method so the write no longer bypasses the schema validation.
In `@modules/auth/tests/auth.integration.tests.js`:
- Around line 712-739: The test title and behavior diverge: update the test to
actually verify "should NOT link when OAuth provider did not verify the email"
by using the same email as the created localUser (set profil.email =
sharedEmail) and keep profil.emailVerifiedByProvider = false, call
AuthController.checkOAuthUserProfile(profil, 'sub', 'google'), then assert that
the returned user is the newly created OAuth user (or that
localUser.additionalProvidersData is still undefined/unchanged) to prove no
linking occurred; ensure the test title and assertions reflect this scenario and
retain cleanup via UserService.remove for both localUser and the created user.
- Around line 676-710: The test seeds a local user with password undefined
because it uses credentials.password (credentials is an array), so replace uses
of credentials.password with a concrete password value (e.g., pick
credentials[0].password or a local literal like 'Test#1234') when creating the
local user via UserService.create in the OAuth-linking tests and any other tests
flagged (the ones invoking UserService.create before
AuthController.checkOAuthUserProfile); ensure the seeded user's password is
non-empty so the assertion about retaining a password-backed provider
(linked.provider === 'local') is valid, and keep cleanup via UserService.remove
unchanged.
- Around line 741-766: Update the test for AuthController.checkOAuthUserProfile
to assert the specific service error and verify the local account wasn't
modified instead of relying on Mongo unique index: call
AuthController.checkOAuthUserProfile(profil, 'sub', 'google') and assert the
rejection matches the expected error object (e.g., code: 'SERVICE_ERROR'), then
use UserService.search({ email: sharedEmail }) to assert exactly one user exists
and that its additionalProvidersData is undefined; keep the existing cleanup via
UserService.remove(localUser).
In `@modules/users/models/users.schema.js`:
- Line 27: The nested additionalProvidersData field currently exposes OAuth
tokens in responses; update the auth token flow to strip those secrets before
serialization by either (A) adding scrubbing for nested keys like
additionalProvidersData.*.{accessToken,refreshToken} to the output whitelist
used during serialization (matching how providerData is excluded), or (B) call
the existing removeSensitive sanitization on the auth controller’s token
response (the same approach used by users.update) after checkOAuthUserProfile
populates additionalProvidersData so that accessToken/refreshToken are removed
before the token endpoint returns to clients.
---
Outside diff comments:
In `@modules/auth/controllers/auth.controller.js`:
- Around line 298-304: The JSDoc for async function checkOAuthUserProfile is
missing a `@returns` and must be updated: add a one-line description (if changed),
keep `@param` entries for profil, key, provider, and add an `@returns` describing
the Promise resolution shape (either a mutated existing User object when linking
or a newly created User object when creating), and add an `@throws` (or `@throws`
{AppError}) line documenting the distinct AppError failure modes the function
can throw; reference the function name checkOAuthUserProfile and ensure the
JSDoc reflects the async return type and error conditions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 354621a1-8604-4498-aceb-222be76e3a40
📒 Files selected for processing (7)
lib/services/tests/analytics.identify.unit.tests.jsmodules/auth/controllers/auth.controller.jsmodules/auth/strategies/local/apple.jsmodules/auth/strategies/local/google.jsmodules/auth/tests/auth.integration.tests.jsmodules/auth/tests/auth.silent.catch.unit.tests.jsmodules/users/models/users.schema.js
There was a problem hiding this comment.
Pull request overview
Adds OAuth account-linking behavior to the auth flow so that an OAuth sign-in can attach to an existing user when the provider vouches for the email, and ensures newly-created OAuth users inherit provider email verification.
Changes:
- Add
emailVerifiedByProviderwiring in Google/Apple strategies and propagate it into user creation (emailVerified). - Extend
checkOAuthUserProfilelookup order to includeadditionalProvidersDataand attempt email-based linking. - Update Zod user schema + adjust unit/integration tests to cover new linking/verification behaviors and new repository dependency.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/auth/controllers/auth.controller.js | Implements linked-provider lookup + email-based linking and sets emailVerified for new OAuth users. |
| modules/auth/strategies/local/google.js | Adds emailVerifiedByProvider from Google email_verified claim. |
| modules/auth/strategies/local/apple.js | Adds emailVerifiedByProvider from Apple email_verified claim (boolean or 'true'). |
| modules/users/models/users.schema.js | Adds additionalProvidersData to the Zod user schema. |
| modules/auth/tests/auth.integration.tests.js | Adds integration tests for linking + verified-email creation behavior. |
| modules/auth/tests/auth.silent.catch.unit.tests.js | Mocks users.repository due to new import in auth controller. |
| lib/services/tests/analytics.identify.unit.tests.js | Mocks users.repository due to new import in auth controller. |
…test accuracy - Remove direct UserRepository import from auth controller (was layer violation) - Replace UserRepository.update with UserService.update(brutUser, patch, 'recover') in step 3 - Add ALLOWED_PROVIDERS/ALLOWED_PROVIDER_KEYS allowlists before dynamic key construction - Add sanitizeAdditionalProvidersData helper to strip accessToken/refreshToken from token endpoint - Add additionalProvidersData to recover whitelist so service update can persist it - Fix credentials.password → credentials[0].password (was array, not object) - Fix unverified-email test to use same email and assert local account untouched - Strengthen takeover test: assert specific error code + verify local account not modified - Remove dead UserRepository mocks from unit tests (controller no longer imports it)
…ot SERVICE_ERROR)
…ions - Replace search+update sequence with atomic findOneAndUpdate via UserRepository.linkProviderByEmail to eliminate TOCTOU race between concurrent OAuth callbacks (CodeRabbit race condition finding) - Expose linkProviderByEmail on UserService with removeSensitive wrapping - Controller step 3 now calls UserService.linkProviderByEmail (single round-trip, no race window) - Add @returns JSDoc to checkOAuthUserProfile, apple.prepare, google.prepare
Summary
checkOAuthUserProfile: when a local user exists with the same email and the OAuth provider vouches for the email, attach the OAuthproviderDataunderuser.additionalProvidersData[provider]instead of erroring on Mongo's unique-email constraint.user.provideruntouched on link — this preserves password reset (gated onprovider === 'local') and local login.emailVerifiedByProvider: truefrom the OAuth strategy before linking. Prevents account takeover via a misconfigured OIDC provider that returnsemail_verified: falsefor someone else's address.emailVerified: truewhen the provider vouches for the email (closes fix(auth): OAuth-created users should have emailVerified=true #3494).email_verifiedthrough the Google (providerData.email_verified) and Apple (decodedIdToken.email_verified, accepts boolean or'true'string) strategies.Closes #3493
Closes #3494
Lookup order in
checkOAuthUserProfile(provider, providerData[key])— primary identity, OAuth-first users.additionalProvidersData[provider][key]— linked users on subsequent signins.emailmatch — if provider verified the email, attachproviderDataand return.emailVerifiedreflecting provider verification).Why not overwrite
user.providerauth.password.controller.js:36rejects password reset whenprovider !== 'local'. Overwriting would silently break password reset for linked users. Keepingproviderstable and storing the OAuth link inadditionalProvidersData(a field that already exists in the Mongoose schema and was already surfaced in/tokenresponses) sidesteps the regression without touching the password flow.Test plan
npm run test:unit— 544/544 green (added UserRepository mock to 2 unit tests that mock the user service)npm run test:integration— 264/264 green (4 new tests cover linking, takeover rejection, fresh-create emailVerified)npm run lint— 0 warnings/update-all-projectsthen retest Google signin on trawl with the pre-existing local accountSecurity notes
email_verified: true(ID token claim) for addresses they authoritatively control. Linking is safe because the OAuth flow proves current ownership of that email.emailVerifiedByProvidergate is future-proofing: if a custom OIDC provider is wired into the strategies stack later and lies aboutemail_verified, linking is refused and we fall through to the existingunique: trueconstraint.Related (separate PRs)
req.bodyundefined (merged, fix(auth): handle GET OAuth callback with undefined req.body on Express 5 #3496)Summary by CodeRabbit
New Features
Tests