Skip to content

Commit c4532ba

Browse files
feat(auth): link OAuth signin to existing user by verified email (#3497)
* feat(auth): link OAuth signin to existing user by verified email (#3493) - 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 * fix(auth): fix layer violation, provider allowlist, token data leak, 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) * fix(auth): fix takeover test error code assertion (CONTROLLER_ERROR not SERVICE_ERROR) * fix(auth): atomic provider linking + JSDoc @returns on modified functions - 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 * test(auth): add coverage for provider/key allowlist validation and token sanitization * docs(migrations): document OAuth account linking + Express 5 callback fix
1 parent 35f28ff commit c4532ba

9 files changed

Lines changed: 337 additions & 18 deletions

File tree

MIGRATIONS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,52 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## OAuth account linking + Express 5 callback fix (2026-04-23)
8+
9+
Two related auth fixes that ship together.
10+
11+
### 1. Express 5 GET callback no longer crashes
12+
13+
Enabling Google OAuth on a downstream project used to crash on first signin with `Cannot read properties of undefined (reading 'strategy')`. Root cause: Express 5 leaves `req.body` as `undefined` on GET requests (Express 4 initialized it to `{}`).
14+
15+
- `modules/auth/controllers/auth.controller.js``oauthCallback` optional-chains `req.body` access
16+
- Apple OAuth (POST `form_post`) was never affected — no change to behavior
17+
18+
### 2. Account linking by verified email
19+
20+
Before: a local signup at `user@x.com` followed by a Google signin with the same email crashed on Mongo's unique-email index (E11000) — user locked out.
21+
22+
Now: `checkOAuthUserProfile` follows a 4-step lookup:
23+
24+
1. `(provider, providerData[key])` — primary identity (OAuth-first users)
25+
2. `additionalProvidersData[provider][key]` — linked users on subsequent signins
26+
3. `email` match **with provider-verified email** → atomic link (`UserService.linkProviderByEmail`)
27+
4. No match → create new user with `emailVerified` reflecting provider verification
28+
29+
Linking attaches the OAuth `providerData` under `user.additionalProvidersData[provider]` and **does not overwrite `user.provider`** — so password reset (gated on `provider === 'local'`) and local login keep working for linked users.
30+
31+
### Security gates
32+
33+
- Provider + key allowlists (`ALLOWED_PROVIDERS = {google, apple}`, `ALLOWED_PROVIDER_KEYS = {id, sub, email}`) validate the dynamic query path before Mongo.
34+
- `emailVerifiedByProvider: true` required before linking — prevents takeover via a future OIDC provider that returns `email_verified: false` for someone else's address.
35+
- `/token` response sanitizes `accessToken` / `refreshToken` out of `additionalProvidersData` before serialization.
36+
37+
### Action for downstream
38+
39+
1. Run `/update-stack` to pull both fixes in one go.
40+
2. Env vars to set in prod K8s for Google (per project that wants OAuth enabled):
41+
- `DEVKIT_NODE_oAuth_google_clientID`
42+
- `DEVKIT_NODE_oAuth_google_clientSecret`
43+
- `DEVKIT_NODE_oAuth_google_callbackURL` — e.g. `https://api.{project}.{tld}/api/auth/google/callback`
44+
3. Register the callback URL in Google Cloud Console (OAuth 2.0 client, Web type). For Apple: same pattern on `decodedIdToken.email_verified`.
45+
4. `/api/auth/config` returns `oAuth.google: true` once the clientID is set — the Vue signin/signup buttons activate automatically via `serverConfig.oAuth.google`.
46+
47+
### Schema note
48+
49+
`additionalProvidersData` already existed in the Mongoose user schema and is now exposed in the Zod user schema too. No Mongo migration needed — existing users have an empty field.
50+
51+
---
52+
753
## Analytics: request-aware feature-flag helpers (2026-04-23)
854

955
`analytics` service gains two sugar helpers that extract the PostHog `distinctId` from an Express request, so routes no longer need to repeat `req.user?.id ?? req.sessionID ?? 'anonymous'` (and can never forget the anonymous fallback):

modules/auth/controllers/auth.controller.js

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,25 @@ const signin = async (req, res) => {
234234
* @param {Object} res - Express response object
235235
* TODO: escape deprecated
236236
*/
237+
/**
238+
* @desc Strip OAuth tokens from an additionalProvidersData map before serializing to client.
239+
* Only the provider identity fields are safe to expose; access/refresh tokens must stay server-side.
240+
* @param {Object|undefined} apd - raw additionalProvidersData
241+
* @returns {Object|undefined} sanitized map with accessToken/refreshToken removed per provider
242+
*/
243+
const sanitizeAdditionalProvidersData = (apd) => {
244+
if (!apd || typeof apd !== 'object') return undefined;
245+
const sanitized = {};
246+
for (const [prov, data] of Object.entries(apd)) {
247+
if (data && typeof data === 'object') {
248+
// eslint-disable-next-line no-unused-vars
249+
const { accessToken, refreshToken, ...safe } = data;
250+
sanitized[prov] = safe;
251+
}
252+
}
253+
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
254+
};
255+
237256
const token = async (req, res) => {
238257
let user = null;
239258
if (req.user) {
@@ -248,7 +267,7 @@ const token = async (req, res) => {
248267
email: req.user.email,
249268
lastName: req.user.lastName,
250269
firstName: req.user.firstName,
251-
additionalProvidersData: req.user.additionalProvidersData,
270+
additionalProvidersData: sanitizeAdditionalProvidersData(req.user.additionalProvidersData),
252271
emailVerified: req.user.emailVerified,
253272
currentOrganization: req.user.currentOrganization,
254273
lastLoginAt: req.user.lastLoginAt,
@@ -295,13 +314,32 @@ const oauthCall = (req, res, next) => {
295314
};
296315

297316
/**
298-
* @desc Endpoint to save oAuthProfile
317+
* Known OAuth providers — used to validate the `provider` argument and `key` argument
318+
* before constructing dynamic query paths, preventing prototype-pollution-style injections.
319+
*/
320+
const ALLOWED_PROVIDERS = new Set(['google', 'apple']);
321+
const ALLOWED_PROVIDER_KEYS = new Set(['sub', 'id', 'email']);
322+
323+
/**
324+
* @desc Resolve or create a user from an OAuth profile. Lookup order:
325+
* 1. Primary identity (provider + providerData[key])
326+
* 2. Linked identity (additionalProvidersData[provider][key])
327+
* 3. Link-on-verified-email (provider-verified email matches an existing local user)
328+
* 4. Create new user
299329
* @param {Object} profil - OAuth user profile object
300-
* @param {string} key - Provider key to lookup providerData
301-
* @param {string} provider - OAuth provider name
330+
* @param {string} key - Provider key to lookup providerData (must be in ALLOWED_PROVIDER_KEYS)
331+
* @param {string} provider - OAuth provider name (must be in ALLOWED_PROVIDERS)
332+
* @returns {Promise<Object>} sanitized user document (existing, linked, or newly created)
302333
*/
303334
const checkOAuthUserProfile = async (profil, key, provider) => {
304-
// check if user exist
335+
// Guard: validate provider and key against allowlists before using as dynamic object keys
336+
if (!ALLOWED_PROVIDERS.has(provider)) {
337+
throw new AppError('oAuth, unsupported provider', { code: 'VALIDATION_ERROR', details: { provider } });
338+
}
339+
if (!ALLOWED_PROVIDER_KEYS.has(key)) {
340+
throw new AppError('oAuth, unsupported provider key', { code: 'VALIDATION_ERROR', details: { key } });
341+
}
342+
// 1. Primary identity: match on (provider, providerData[key]) — OAuth-first users
305343
try {
306344
const query = {};
307345
query[`providerData.${key}`] = profil.providerData[key];
@@ -311,7 +349,28 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
311349
} catch (err) {
312350
throw new AppError('oAuth, find user failed', { code: 'SERVICE_ERROR', details: err });
313351
}
314-
// if no, generate
352+
// 2. Linked identity: match on additionalProvidersData[provider][key] — locals already linked
353+
try {
354+
const query = {};
355+
query[`additionalProvidersData.${provider}.${key}`] = profil.providerData[key];
356+
const search = await UserService.search(query);
357+
if (search.length === 1) return search[0];
358+
} catch (err) {
359+
throw new AppError('oAuth, find linked user failed', { code: 'SERVICE_ERROR', details: err });
360+
}
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.
365+
if (profil.email && profil.emailVerifiedByProvider) {
366+
try {
367+
const linked = await UserService.linkProviderByEmail(profil.email, provider, profil.providerData);
368+
if (linked) return linked;
369+
} catch (err) {
370+
throw new AppError('oAuth, link to existing user failed', { code: 'SERVICE_ERROR', details: err });
371+
}
372+
}
373+
// 4. No match → create new user
315374
try {
316375
const user = {
317376
firstName: profil.firstName,
@@ -320,6 +379,7 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
320379
avatar: profil.avatar || '',
321380
provider,
322381
providerData: profil.providerData || null,
382+
emailVerified: !!profil.emailVerifiedByProvider,
323383
};
324384
const result = model.getResultFromZod(user, UsersSchema.User);
325385
// check error

modules/auth/strategies/local/apple.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ const callbackURL = `${config.api.protocol}://${config.api.host}${config.api.por
1212
}/auth/apple/callback`;
1313

1414
/**
15-
* @desc function to prepare map callback to user profile
16-
* @param {req}
17-
* @param {accessToken}
18-
* @param {refreshToken}
19-
* @param {profile}
20-
* @param {cb} callback
15+
* @desc Map Apple OAuth callback to user profile and delegate to checkOAuthUserProfile
16+
* @param {Object} req - Express request (passReqToCallback)
17+
* @param {string} accessToken - Apple access token
18+
* @param {string} refreshToken - Apple refresh token
19+
* @param {Object} decodedIdToken - Decoded Apple ID token
20+
* @param {Object} profile - Apple profile (may be empty on repeat sign-ins)
21+
* @param {Function} cb - Passport callback (err, user)
22+
* @returns {Promise<void>}
2123
*/
2224
const prepare = async (req, accessToken, refreshToken, decodedIdToken, profile, cb) => {
2325
// Set the provider data and include tokens
@@ -35,6 +37,7 @@ const prepare = async (req, accessToken, refreshToken, decodedIdToken, profile,
3537
avatar: null,
3638
provider: 'apple',
3739
providerData,
40+
emailVerifiedByProvider: decodedIdToken.email_verified === true || decodedIdToken.email_verified === 'true',
3841
};
3942
// Save the user OAuth profile
4043
try {

modules/auth/strategies/local/google.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ const callbackURL = `${config.api.protocol}://${config.api.host}${config.api.por
1212
}/auth/google/callback`;
1313

1414
/**
15-
* @desc function to prepare map callback to user profile
16-
* @param {accessToken}
17-
* @param {refreshToken}
18-
* @param {profile}
19-
* @param {cb} callback
15+
* @desc Map Google OAuth callback to user profile and delegate to checkOAuthUserProfile
16+
* @param {string} accessToken - Google access token
17+
* @param {string} refreshToken - Google refresh token
18+
* @param {Object} profile - Google profile object
19+
* @param {Function} cb - Passport callback (err, user)
20+
* @returns {Promise<void>}
2021
*/
2122
const prepare = async (accessToken, refreshToken, profile, cb) => {
2223
// Set the provider data and include tokens
@@ -31,6 +32,7 @@ const prepare = async (accessToken, refreshToken, profile, cb) => {
3132
avatar: providerData.picture ? providerData.picture : undefined,
3233
provider: 'google',
3334
providerData,
35+
emailVerifiedByProvider: providerData.email_verified === true,
3436
};
3537
// Save the user OAuth profile
3638
try {

0 commit comments

Comments
 (0)