From 7669bd9295dae7589d37f90f6de952612bc4df78 Mon Sep 17 00:00:00 2001 From: Aleksei Egovkin Date: Thu, 4 Sep 2025 15:20:19 +0200 Subject: [PATCH 1/4] Optimize authData handling logic and integrate delta-based updates Refactor authData processing for improved clarity, efficiency, and correctness. Introduce `diffAuthData` and `subsetEqual` to handle partial updates, unlinking, and change detection intelligently. Update `RestWrite` logic to utilize refined authData delta behavior, preventing redundant operations. Add comprehensive test coverage for edge cases and optimizations. Refactor existing password policy validations for readability. --- spec/AuthenticationAdaptersV2.spec.js | 1 + spec/Users.authdata.spec.js | 260 ++++++++++++++++++++++ src/Auth.js | 77 ++++++- src/RestWrite.js | 298 +++++++++++++++++--------- 4 files changed, 528 insertions(+), 108 deletions(-) create mode 100644 spec/Users.authdata.spec.js diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 7301ab54c1..abbe8de73d 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -829,6 +829,7 @@ describe('Auth Adapter features', () => { expect(firstCall[2].user.id).toEqual(user.id); expect(firstCall.length).toEqual(3); + payload.someData = false; await user.save({ authData: { baseAdapter2: payload } }, { useMasterKey: true }); const secondCall = baseAdapter2.validateAuthData.calls.argsFor(1); diff --git a/spec/Users.authdata.spec.js b/spec/Users.authdata.spec.js new file mode 100644 index 0000000000..8e8c2b0c71 --- /dev/null +++ b/spec/Users.authdata.spec.js @@ -0,0 +1,260 @@ +describe('AuthData Delta Behavior', () => { + const MOCK_USER_ID = 'mockUserId'; + const MOCK_ACCESS_TOKEN = 'mockAccessToken123'; + + const createMockUser = () => ({ + id: MOCK_USER_ID, + code: 'C1' + }); + + const mockGooglePlayGamesAPI = () => { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: MOCK_ACCESS_TOKEN }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${MOCK_USER_ID}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: MOCK_USER_ID }), + }, + }, + ]); + }; + + const setupAuthConfig = (additionalProviders = {}) => { + return reconfigureServer({ + auth: { + gpgames: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + someAdapter1: { + validateAuthData: () => Promise.resolve(), + validateAppId: () => Promise.resolve(), + validateOptions: () => {}, + }, + someAdapter2: { + validateAuthData: () => Promise.resolve(), + validateAppId: () => Promise.resolve(), + validateOptions: () => {}, + }, + ...additionalProviders, + }, + }); + }; + + beforeEach(async () => { + await setupAuthConfig(); + }); + + describe('Provider Linking', () => { + it('should link someAdapter1 without affecting unchanged Google Play Games auth', async () => { + mockGooglePlayGamesAPI(); + + const authData = createMockUser(); + const user = await Parse.User.logInWith('gpgames', { authData }); + const sessionToken = user.getSessionToken(); + + await user.fetch({ sessionToken }); + const currentAuthData = user.get('authData') || {}; + + user.set('authData', { + ...currentAuthData, + someAdapter1: { id: 'T1', access_token: 'token123' }, + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData'); + + expect(finalAuthData.gpgames?.id).toBe(MOCK_USER_ID); + expect(finalAuthData.someAdapter1?.id).toBe('T1'); + }); + + it('should handle multiple providers correctly', async () => { + mockGooglePlayGamesAPI(); + + const authData = { + gpgames: { id: MOCK_USER_ID, code: 'C4' }, + someAdapter2: { id: 'F1', access_token: 'fb_token' }, + }; + + const user = new Parse.User(); + user.set('authData', authData); + await user.save(); + + const sessionToken = user.getSessionToken(); + + await user.fetch({ sessionToken }); + const currentAuthData = user.get('authData') || {}; + + user.set('authData', { + someAdapter2: currentAuthData.someAdapter2, + someAdapter1: { id: 'T2', access_token: 'tw_token' }, + gpgames: null, // Unlink Google Play Games + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData') || {}; + + expect(finalAuthData.gpgames).toBeUndefined(); + expect(finalAuthData.someAdapter2?.id).toBe('F1'); + expect(finalAuthData.someAdapter1?.id).toBe('T2'); + }); + }); + + describe('Provider Unlinking', () => { + it('should unlink provider via null', async () => { + mockGooglePlayGamesAPI(); + + const authData = createMockUser(); + const user = await Parse.User.logInWith('gpgames', { authData }); + const sessionToken = user.getSessionToken(); + + await user.fetch({ sessionToken }); + const currentAuthData = user.get('authData') || {}; + + user.set('authData', { + ...currentAuthData, + gpgames: null, + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData') || {}; + + expect(finalAuthData.gpgames).toBeUndefined(); + }); + }); + + describe('Data Validation Optimization', () => { + it('should skip revalidation when authData is identical', async () => { + mockGooglePlayGamesAPI(); + + const authData = createMockUser(); + const user = await Parse.User.logInWith('gpgames', { authData }); + const sessionToken = user.getSessionToken(); + + await user.fetch({ sessionToken }); + const existingAuthData = user.get('authData'); + + // Small delay to ensure timestamp differences don't affect comparison + await new Promise(resolve => setTimeout(resolve, 100)); + + user.set('authData', JSON.parse(JSON.stringify(existingAuthData))); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData') || {}; + + expect(finalAuthData.gpgames?.id).toBe(MOCK_USER_ID); + }); + + it('should handle empty authData gracefully', async () => { + mockGooglePlayGamesAPI(); + + const user = await Parse.User.signUp('test', 'password123'); + + const sessionToken = user.getSessionToken(); + await user.fetch({ sessionToken }); + + user.set('authData', { + someAdapter1: { id: 'T3', access_token: 'token456' }, + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData'); + + expect(finalAuthData).toBeDefined(); + expect(finalAuthData.someAdapter1?.id).toBe('T3'); + }); + }); + + describe('Partial Data Updates', () => { + it('should handle partial provider data updates correctly', async () => { + mockGooglePlayGamesAPI() + + const authData = createMockUser(); + const user = await Parse.User.logInWith('gpgames', { authData }); + + const sessionToken = user.getSessionToken(); + + await user.fetch({ sessionToken }); + + const currentAuthData = user.get('authData') || {}; + user.set('authData', { + ...currentAuthData, + gpgames: { + ...currentAuthData.gpgames, + code: 'new', + }, + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData'); + + expect(finalAuthData.gpgames.id).toBe(MOCK_USER_ID); + }); + }); + + describe('API Call Optimization', () => { + beforeEach(async () => { + await setupAuthConfig(); + }); + + it('should not call getAccessTokenFromCode for unchanged authData', async () => { + mockGooglePlayGamesAPI(); + + const authData = createMockUser(); + const user = await Parse.User.logInWith('gpgames', { authData }); + const sessionToken = user.getSessionToken(); + + const initialCallCount = global.fetch.calls.count(); + + const freshUser = await new Parse.Query(Parse.User).get(user.id, { sessionToken }); + const currentAuthData = freshUser.get('authData'); + + freshUser.set('authData', JSON.parse(JSON.stringify(currentAuthData))); + await freshUser.save(null, { sessionToken }); + + expect(global.fetch.calls.count()).toBe(initialCallCount); + }); + + it('should handle mixed authData operations without redundant API calls', async () => { + mockGooglePlayGamesAPI(); + + const authData = createMockUser(); + const user = await Parse.User.logInWith('gpgames', { authData }); + const sessionToken = user.getSessionToken(); + + const initialCallCount = global.fetch.calls.count(); + + const freshUser = await new Parse.Query(Parse.User).get(user.id, { sessionToken }); + const currentAuthData = freshUser.get('authData') || {}; + + freshUser.set('authData', { + ...currentAuthData, + someAdapter2: { id: 'fb123', access_token: 'fb_token' } + }); + await freshUser.save(null, { sessionToken }); + + expect(global.fetch.calls.count()).toBe(initialCallCount); + + const finalUser = await new Parse.Query(Parse.User).get(user.id, { sessionToken }); + const finalAuthData = finalUser.get('authData') || {}; + + expect(finalAuthData.gpgames?.id).toBe(MOCK_USER_ID); + expect(finalAuthData.someAdapter2?.id).toBe('fb123'); + }); + }); +}); diff --git a/src/Auth.js b/src/Auth.js index d8bf7e651f..7fa1c2e692 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -423,10 +423,15 @@ const findUsersWithAuthData = async (config, authData, beforeFind) => { const queries = await Promise.all( providers.map(async provider => { const providerAuthData = authData[provider]; + if (!providerAuthData) { + return null; + } - const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; - if (beforeFind && typeof adapter?.beforeFind === 'function') { - await adapter.beforeFind(providerAuthData); + if (beforeFind) { + const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; + if (typeof adapter?.beforeFind === 'function') { + await adapter.beforeFind(providerAuthData); + } } if (!providerAuthData?.id) { @@ -601,6 +606,70 @@ const handleAuthDataValidation = async (authData, req, foundUser) => { return acc; }; +const subsetEqual = (prev, next) => { + if (prev === next) return true; + if (prev == null || next == null) return false; + + const tp = typeof prev; + const tn = typeof next; + if (tn !== 'object' || tp !== 'object') return prev === next; + + // arrays: require element-wise equality for the provided portion + if (Array.isArray(next)) { + if (!Array.isArray(prev)) return false; + if (next.length !== prev.length) return false; + for (let i = 0; i < next.length; i++) { + if (!subsetEqual(prev[i], next[i])) return false; + } + return true; + } + + // objects: every provided key in `next` must match `prev` + for (const k of Object.keys(next)) { + const nv = next[k]; + if (typeof nv === 'undefined') continue; // treat "not provided" as no-op + const pv = prev[k]; + if (!subsetEqual(pv, nv)) return false; + } + return true; +} + +/** + * Delta between current and incoming authData with partial update semantics: + * - changed: providers truly changed (new or value differs on provided keys) + * - unlink: providers explicitly set to null (remove without validation) + * - unchanged: providers either absent in incoming or provided as matching subset + */ +const diffAuthData = (current = {}, incoming = {}) => { + const changed = {}; + const unlink = {}; + const unchanged = {}; + + const providers = new Set([...Object.keys(current), ...Object.keys(incoming)]); + for (const p of providers) { + const prev = current[p]; + const next = incoming[p]; + + if (next === null) { unlink[p] = true; continue; } + if (typeof next === 'undefined') { // provider untouched + if (typeof prev !== 'undefined') unchanged[p] = prev; + continue; + } + if (typeof prev === 'undefined') { // new provider + changed[p] = next; + continue; + } + + // key point: treat sanitized partial payload (subset) as unchanged + if (subsetEqual(prev, next)) { + unchanged[p] = prev; + } else { + changed[p] = next; + } + } + return { changed, unlink, unchanged }; +}; + module.exports = { Auth, master, @@ -614,4 +683,6 @@ module.exports = { hasMutatedAuthData, checkIfUserHasProvidedConfiguredProvidersForLogin, handleAuthDataValidation, + subsetEqual, + diffAuthData }; diff --git a/src/RestWrite.js b/src/RestWrite.js index 78dd8c8878..15dc9f0fde 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -155,12 +155,24 @@ RestWrite.prototype.execute = function () { return this.cleanUserAuthData(); }) .then(() => { + if (this.storage.returnUserWithSession) { + this.response = this.response || { + response: {}, + location: this.location(), + }; + + this.response.response = this.response.response || {}; + this.response.response.sessionToken = + this.response.response.sessionToken || this.auth.user?.getSessionToken?.(); + } + // Append the authDataResponse if exists if (this.authDataResponse) { if (this.response && this.response.response) { this.response.response.authDataResponse = this.authDataResponse; } } + if (this.storage.rejectSignup && this.config.preventSignupWithUnverifiedEmail) { throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); } @@ -523,9 +535,11 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () { key => this.data.authData[key] && this.data.authData[key].id ); - if (!hasAuthDataId) { return; } + if (!hasAuthDataId) { + return; + } - const r = await Auth.findUsersWithAuthData(this.config, this.data.authData); + const r = await Auth.findUsersWithAuthData(this.config, this.data.authData, false); const results = this.filteredObjectsByACL(r); if (results.length > 1) { throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); @@ -538,17 +552,30 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () { }; RestWrite.prototype.handleAuthData = async function (authData) { - const r = await Auth.findUsersWithAuthData(this.config, authData, true); - const results = this.filteredObjectsByACL(r); + let userId = this.getUserId(); + if (!userId && this.auth.isMaster) { + userId = this.query?.objectId || null; + } + + const isLogin = !userId; + + let authDataBeforeFindTriggered = false; + let usersResult = []; + if (userId) { + usersResult = await this.config.database.find('_User', { objectId: userId }); + } else { + usersResult = await Auth.findUsersWithAuthData(this.config, authData, true); + authDataBeforeFindTriggered = true; + } - const userId = this.getUserId(); - const userResult = results[0]; - const foundUserIsNotCurrentUser = userId && userResult && userId !== userResult.objectId; + const results = this.filteredObjectsByACL(usersResult); + const existingUser = results.length > 0 ? results[0] : null; + const foundUserIsNotCurrentUser = userId && existingUser && userId !== existingUser.objectId; if (results.length > 1 || foundUserIsNotCurrentUser) { // To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5 // Let's run some validation before throwing - await Auth.handleAuthDataValidation(authData, this, userResult); + await Auth.handleAuthDataValidation(authData, this, existingUser); throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } @@ -565,91 +592,139 @@ RestWrite.prototype.handleAuthData = async function (authData) { } // User found with provided authData - if (results.length === 1) { + if (results.length !== 1 && !isLogin) { + return; + } - this.storage.authProvider = Object.keys(authData).join(','); + const { changed, unlink, unchanged } = Auth.diffAuthData(existingUser.authData, authData); + const hasChanges = Object.keys(changed).length > 0; + const hasUnlink = Object.keys(unlink).length > 0; + const hasMutatedAuthData = hasChanges || hasUnlink; - const { hasMutatedAuthData, mutatedAuthData } = Auth.hasMutatedAuthData( - authData, - userResult.authData - ); + const mutatedAuthData = {}; + const withoutUnlinked = {}; + for (const key of Object.keys(authData)) { + if (!unlink[key]) { + withoutUnlinked[key] = authData[key]; + } - const isCurrentUserLoggedOrMaster = - (this.auth && this.auth.user && this.auth.user.id === userResult.objectId) || - this.auth.isMaster; + if (!unchanged[key]) { + mutatedAuthData[key] = authData[key]; + } + } - const isLogin = !userId; + if (!authDataBeforeFindTriggered && hasChanges) { + const r = await Auth.findUsersWithAuthData(this.config, changed, true); + const results = this.filteredObjectsByACL(r); + const foundUser = results.length > 0 ? results[0] : null; + const foundUserIsNotCurrentUser = userId && foundUser && userId !== foundUser.objectId; - if (isLogin || isCurrentUserLoggedOrMaster) { - // no user making the call - // OR the user making the call is the right one - // Login with auth data - delete results[0].password; + if (results.length > 1 || foundUserIsNotCurrentUser) { + // To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5 + // Let's run some validation before throwing + await Auth.handleAuthDataValidation(authData, this, existingUser); + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + } - // need to set the objectId first otherwise location has trailing undefined - this.data.objectId = userResult.objectId; + this.storage.authProvider = Object.keys(withoutUnlinked).join(','); - if (!this.query || !this.query.objectId) { - this.response = { - response: userResult, - location: this.location(), - }; - // Run beforeLogin hook before storing any updates - // to authData on the db; changes to userResult - // will be ignored. - await this.runBeforeLoginTrigger(deepcopy(userResult)); - - // If we are in login operation via authData - // we need to be sure that the user has provided - // required authData - Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( - { config: this.config, auth: this.auth }, - authData, - userResult.authData, - this.config - ); - } + const isCurrentUserLoggedOrMaster = + (this.auth && this.auth.user && this.auth.user.id === existingUser.objectId) || + this.auth.isMaster; - // Prevent validating if no mutated data detected on update - if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) { - return; - } + if (isLogin || isCurrentUserLoggedOrMaster) { + // no user making the call + // OR the user making the call is the right one + // Login with auth data + delete results[0].password; - // Force to validate all provided authData on login - // on update only validate mutated ones - if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { - const res = await Auth.handleAuthDataValidation( - isLogin ? authData : mutatedAuthData, - this, - userResult - ); - this.data.authData = res.authData; - this.authDataResponse = res.authDataResponse; + this.storage.returnUserWithSession = true; + const authDataResponse = {}; + for (const key of Object.keys(withoutUnlinked)) { + if (withoutUnlinked[key] && withoutUnlinked[key].id) { + if (authData[key]) { + authDataResponse[key] = authData[key]; + } + authDataResponse[key] = authDataResponse[key] || {}; + authDataResponse[key].id = withoutUnlinked[key].id; } + } - // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... - // we need to set it up there. - // We are supposed to have a response only on LOGIN with authData, so we skip those - // If we're not logging in, but just updating the current user, we can safely skip that part - if (this.response) { - // Assign the new authData in the response - Object.keys(mutatedAuthData).forEach(provider => { - this.response.response.authData[provider] = mutatedAuthData[provider]; - }); + // need to set the objectId first otherwise location has trailing undefined + this.data.objectId = existingUser.objectId; - // Run the DB update directly, as 'master' only if authData contains some keys - // authData could not contains keys after validation if the authAdapter - // uses the `doNotSave` option. Just update the authData part - // Then we're good for the user, early exit of sorts - if (Object.keys(this.data.authData).length) { - await this.config.database.update( - this.className, - { objectId: this.data.objectId }, - { authData: this.data.authData }, - {} - ); + if (!this.query || !this.query.objectId) { + this.response = { + response: existingUser, + location: this.location(), + }; + // Run beforeLogin hook before storing any updates + // to authData on the db; changes to existingUser + // will be ignored. + await this.runBeforeLoginTrigger(deepcopy(existingUser)); + + // If we are in login operation via authData + // we need to be sure that the user has provided + // required authData + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + { config: this.config, auth: this.auth }, + authData, + existingUser.authData, + this.config + ); + } + + if (!this.authDataResponse && !!Object.keys(authDataResponse).length) { + this.authDataResponse = authDataResponse; + } + + // Prevent validating if no mutated data detected on update + if (!hasMutatedAuthData && !isLogin) { + return; + } + + // Force to validate all provided authData on login + // on update only validate mutated ones + if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { + const res = await Auth.handleAuthDataValidation( + isLogin ? withoutUnlinked : changed, + this, + existingUser + ); + + if (hasUnlink) { + for (const key of Object.keys(unlink)) { + res.authData[key] = null; } } + + this.data.authData = res.authData; + this.authDataResponse = Object.keys(res.authDataResponse).length ? res.authDataResponse : undefined; + } + + // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... + // we need to set it up there. + // We are supposed to have a response only on LOGIN with authData, so we skip those + // If we're not logging in, but just updating the current user, we can safely skip that part + if (this.response) { + // Assign the new authData in the response + + const isEmpty = Object.keys(withoutUnlinked).length === 0; + this.response.response.authData = isEmpty ? undefined : withoutUnlinked; + + // Run the DB update directly, as 'master' only if authData contains some keys + // authData could not contains keys after validation if the authAdapter + // uses the `doNotSave` option. Just update the authData part + // Then we're good for the user, early exit of sorts + if (Object.keys(this.data.authData).length) { + await this.config.database.update( + this.className, + { objectId: this.data.objectId }, + { authData: this.data.authData }, + {} + ); + } } } }; @@ -828,7 +903,9 @@ RestWrite.prototype._validateEmail = function () { }; RestWrite.prototype._validatePasswordPolicy = function () { - if (!this.config.passwordPolicy) { return Promise.resolve(); } + if (!this.config.passwordPolicy) { + return Promise.resolve(); + } return this._validatePasswordRequirements().then(() => { return this._validatePasswordHistory(); }); @@ -862,18 +939,20 @@ RestWrite.prototype._validatePasswordRequirements = function () { if (this.config.passwordPolicy.doNotAllowUsername === true) { if (this.data.username) { // username is not passed during password reset - if (this.data.password.indexOf(this.data.username) >= 0) - { return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError)); } + if (this.data.password.indexOf(this.data.username) >= 0) { + return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError)); + } } else { // retrieve the User object using objectId during password reset return this.config.database.find('_User', { objectId: this.objectId() }).then(results => { if (results.length != 1) { throw undefined; } - if (this.data.password.indexOf(results[0].username) >= 0) - { return Promise.reject( - new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError) - ); } + if (this.data.password.indexOf(results[0].username) >= 0) { + return Promise.reject( + new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError) + ); + } return Promise.resolve(); }); } @@ -897,19 +976,21 @@ RestWrite.prototype._validatePasswordHistory = function () { } const user = results[0]; let oldPasswords = []; - if (user._password_history) - { oldPasswords = _.take( - user._password_history, - this.config.passwordPolicy.maxPasswordHistory - 1 - ); } + if (user._password_history) { + oldPasswords = _.take( + user._password_history, + this.config.passwordPolicy.maxPasswordHistory - 1 + ); + } oldPasswords.push(user.password); const newPassword = this.data.password; // compare the new password hash with all old password hashes const promises = oldPasswords.map(function (hash) { return passwordCrypto.compare(newPassword, hash).then(result => { - if (result) - // reject if there is a match - { return Promise.reject('REPEAT_PASSWORD'); } + if (result) { + // reject if there is a match + return Promise.reject('REPEAT_PASSWORD'); + } return Promise.resolve(); }); }); @@ -919,14 +1000,15 @@ RestWrite.prototype._validatePasswordHistory = function () { return Promise.resolve(); }) .catch(err => { - if (err === 'REPEAT_PASSWORD') - // a match was found - { return Promise.reject( - new Parse.Error( - Parse.Error.VALIDATION_ERROR, - `New password should not be the same as last ${this.config.passwordPolicy.maxPasswordHistory} passwords.` - ) - ); } + if (err === 'REPEAT_PASSWORD') { + // a match was found + return Promise.reject( + new Parse.Error( + Parse.Error.VALIDATION_ERROR, + `New password should not be the same as last ${this.config.passwordPolicy.maxPasswordHistory} passwords.` + ) + ); + } throw err; }); }); @@ -960,10 +1042,16 @@ RestWrite.prototype.createSessionTokenIfNeeded = async function () { // Get verification conditions which can be booleans or functions; the purpose of this async/await // structure is to avoid unnecessarily executing subsequent functions if previous ones fail in the // conditional statement below, as a developer may decide to execute expensive operations in them - const verifyUserEmails = async () => this.config.verifyUserEmails === true || (typeof this.config.verifyUserEmails === 'function' && await Promise.resolve(this.config.verifyUserEmails(request)) === true); - const preventLoginWithUnverifiedEmail = async () => this.config.preventLoginWithUnverifiedEmail === true || (typeof this.config.preventLoginWithUnverifiedEmail === 'function' && await Promise.resolve(this.config.preventLoginWithUnverifiedEmail(request)) === true); + const verifyUserEmails = async () => + this.config.verifyUserEmails === true || + (typeof this.config.verifyUserEmails === 'function' && + (await Promise.resolve(this.config.verifyUserEmails(request))) === true); + const preventLoginWithUnverifiedEmail = async () => + this.config.preventLoginWithUnverifiedEmail === true || + (typeof this.config.preventLoginWithUnverifiedEmail === 'function' && + (await Promise.resolve(this.config.preventLoginWithUnverifiedEmail(request))) === true); // If verification is required - if (await verifyUserEmails() && await preventLoginWithUnverifiedEmail()) { + if ((await verifyUserEmails()) && (await preventLoginWithUnverifiedEmail())) { this.storage.rejectSignup = true; return; } From 7db1f8eaaa0a80f04d8e2963f4ffdde6662c07c9 Mon Sep 17 00:00:00 2001 From: Aleksei Egovkin Date: Tue, 9 Sep 2025 01:11:46 +0200 Subject: [PATCH 2/4] Revert "Optimize authData handling logic and integrate delta-based updates" This reverts commit 7669bd9295dae7589d37f90f6de952612bc4df78. --- spec/AuthenticationAdaptersV2.spec.js | 1 - spec/Users.authdata.spec.js | 260 ---------------------- src/Auth.js | 77 +------ src/RestWrite.js | 298 +++++++++----------------- 4 files changed, 108 insertions(+), 528 deletions(-) delete mode 100644 spec/Users.authdata.spec.js diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index abbe8de73d..7301ab54c1 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -829,7 +829,6 @@ describe('Auth Adapter features', () => { expect(firstCall[2].user.id).toEqual(user.id); expect(firstCall.length).toEqual(3); - payload.someData = false; await user.save({ authData: { baseAdapter2: payload } }, { useMasterKey: true }); const secondCall = baseAdapter2.validateAuthData.calls.argsFor(1); diff --git a/spec/Users.authdata.spec.js b/spec/Users.authdata.spec.js deleted file mode 100644 index 8e8c2b0c71..0000000000 --- a/spec/Users.authdata.spec.js +++ /dev/null @@ -1,260 +0,0 @@ -describe('AuthData Delta Behavior', () => { - const MOCK_USER_ID = 'mockUserId'; - const MOCK_ACCESS_TOKEN = 'mockAccessToken123'; - - const createMockUser = () => ({ - id: MOCK_USER_ID, - code: 'C1' - }); - - const mockGooglePlayGamesAPI = () => { - mockFetch([ - { - url: 'https://oauth2.googleapis.com/token', - method: 'POST', - response: { - ok: true, - json: () => Promise.resolve({ access_token: MOCK_ACCESS_TOKEN }), - }, - }, - { - url: `https://www.googleapis.com/games/v1/players/${MOCK_USER_ID}`, - method: 'GET', - response: { - ok: true, - json: () => Promise.resolve({ playerId: MOCK_USER_ID }), - }, - }, - ]); - }; - - const setupAuthConfig = (additionalProviders = {}) => { - return reconfigureServer({ - auth: { - gpgames: { - clientId: 'validClientId', - clientSecret: 'validClientSecret', - }, - someAdapter1: { - validateAuthData: () => Promise.resolve(), - validateAppId: () => Promise.resolve(), - validateOptions: () => {}, - }, - someAdapter2: { - validateAuthData: () => Promise.resolve(), - validateAppId: () => Promise.resolve(), - validateOptions: () => {}, - }, - ...additionalProviders, - }, - }); - }; - - beforeEach(async () => { - await setupAuthConfig(); - }); - - describe('Provider Linking', () => { - it('should link someAdapter1 without affecting unchanged Google Play Games auth', async () => { - mockGooglePlayGamesAPI(); - - const authData = createMockUser(); - const user = await Parse.User.logInWith('gpgames', { authData }); - const sessionToken = user.getSessionToken(); - - await user.fetch({ sessionToken }); - const currentAuthData = user.get('authData') || {}; - - user.set('authData', { - ...currentAuthData, - someAdapter1: { id: 'T1', access_token: 'token123' }, - }); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData'); - - expect(finalAuthData.gpgames?.id).toBe(MOCK_USER_ID); - expect(finalAuthData.someAdapter1?.id).toBe('T1'); - }); - - it('should handle multiple providers correctly', async () => { - mockGooglePlayGamesAPI(); - - const authData = { - gpgames: { id: MOCK_USER_ID, code: 'C4' }, - someAdapter2: { id: 'F1', access_token: 'fb_token' }, - }; - - const user = new Parse.User(); - user.set('authData', authData); - await user.save(); - - const sessionToken = user.getSessionToken(); - - await user.fetch({ sessionToken }); - const currentAuthData = user.get('authData') || {}; - - user.set('authData', { - someAdapter2: currentAuthData.someAdapter2, - someAdapter1: { id: 'T2', access_token: 'tw_token' }, - gpgames: null, // Unlink Google Play Games - }); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData') || {}; - - expect(finalAuthData.gpgames).toBeUndefined(); - expect(finalAuthData.someAdapter2?.id).toBe('F1'); - expect(finalAuthData.someAdapter1?.id).toBe('T2'); - }); - }); - - describe('Provider Unlinking', () => { - it('should unlink provider via null', async () => { - mockGooglePlayGamesAPI(); - - const authData = createMockUser(); - const user = await Parse.User.logInWith('gpgames', { authData }); - const sessionToken = user.getSessionToken(); - - await user.fetch({ sessionToken }); - const currentAuthData = user.get('authData') || {}; - - user.set('authData', { - ...currentAuthData, - gpgames: null, - }); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData') || {}; - - expect(finalAuthData.gpgames).toBeUndefined(); - }); - }); - - describe('Data Validation Optimization', () => { - it('should skip revalidation when authData is identical', async () => { - mockGooglePlayGamesAPI(); - - const authData = createMockUser(); - const user = await Parse.User.logInWith('gpgames', { authData }); - const sessionToken = user.getSessionToken(); - - await user.fetch({ sessionToken }); - const existingAuthData = user.get('authData'); - - // Small delay to ensure timestamp differences don't affect comparison - await new Promise(resolve => setTimeout(resolve, 100)); - - user.set('authData', JSON.parse(JSON.stringify(existingAuthData))); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData') || {}; - - expect(finalAuthData.gpgames?.id).toBe(MOCK_USER_ID); - }); - - it('should handle empty authData gracefully', async () => { - mockGooglePlayGamesAPI(); - - const user = await Parse.User.signUp('test', 'password123'); - - const sessionToken = user.getSessionToken(); - await user.fetch({ sessionToken }); - - user.set('authData', { - someAdapter1: { id: 'T3', access_token: 'token456' }, - }); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData'); - - expect(finalAuthData).toBeDefined(); - expect(finalAuthData.someAdapter1?.id).toBe('T3'); - }); - }); - - describe('Partial Data Updates', () => { - it('should handle partial provider data updates correctly', async () => { - mockGooglePlayGamesAPI() - - const authData = createMockUser(); - const user = await Parse.User.logInWith('gpgames', { authData }); - - const sessionToken = user.getSessionToken(); - - await user.fetch({ sessionToken }); - - const currentAuthData = user.get('authData') || {}; - user.set('authData', { - ...currentAuthData, - gpgames: { - ...currentAuthData.gpgames, - code: 'new', - }, - }); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData'); - - expect(finalAuthData.gpgames.id).toBe(MOCK_USER_ID); - }); - }); - - describe('API Call Optimization', () => { - beforeEach(async () => { - await setupAuthConfig(); - }); - - it('should not call getAccessTokenFromCode for unchanged authData', async () => { - mockGooglePlayGamesAPI(); - - const authData = createMockUser(); - const user = await Parse.User.logInWith('gpgames', { authData }); - const sessionToken = user.getSessionToken(); - - const initialCallCount = global.fetch.calls.count(); - - const freshUser = await new Parse.Query(Parse.User).get(user.id, { sessionToken }); - const currentAuthData = freshUser.get('authData'); - - freshUser.set('authData', JSON.parse(JSON.stringify(currentAuthData))); - await freshUser.save(null, { sessionToken }); - - expect(global.fetch.calls.count()).toBe(initialCallCount); - }); - - it('should handle mixed authData operations without redundant API calls', async () => { - mockGooglePlayGamesAPI(); - - const authData = createMockUser(); - const user = await Parse.User.logInWith('gpgames', { authData }); - const sessionToken = user.getSessionToken(); - - const initialCallCount = global.fetch.calls.count(); - - const freshUser = await new Parse.Query(Parse.User).get(user.id, { sessionToken }); - const currentAuthData = freshUser.get('authData') || {}; - - freshUser.set('authData', { - ...currentAuthData, - someAdapter2: { id: 'fb123', access_token: 'fb_token' } - }); - await freshUser.save(null, { sessionToken }); - - expect(global.fetch.calls.count()).toBe(initialCallCount); - - const finalUser = await new Parse.Query(Parse.User).get(user.id, { sessionToken }); - const finalAuthData = finalUser.get('authData') || {}; - - expect(finalAuthData.gpgames?.id).toBe(MOCK_USER_ID); - expect(finalAuthData.someAdapter2?.id).toBe('fb123'); - }); - }); -}); diff --git a/src/Auth.js b/src/Auth.js index 7fa1c2e692..d8bf7e651f 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -423,15 +423,10 @@ const findUsersWithAuthData = async (config, authData, beforeFind) => { const queries = await Promise.all( providers.map(async provider => { const providerAuthData = authData[provider]; - if (!providerAuthData) { - return null; - } - if (beforeFind) { - const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; - if (typeof adapter?.beforeFind === 'function') { - await adapter.beforeFind(providerAuthData); - } + const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; + if (beforeFind && typeof adapter?.beforeFind === 'function') { + await adapter.beforeFind(providerAuthData); } if (!providerAuthData?.id) { @@ -606,70 +601,6 @@ const handleAuthDataValidation = async (authData, req, foundUser) => { return acc; }; -const subsetEqual = (prev, next) => { - if (prev === next) return true; - if (prev == null || next == null) return false; - - const tp = typeof prev; - const tn = typeof next; - if (tn !== 'object' || tp !== 'object') return prev === next; - - // arrays: require element-wise equality for the provided portion - if (Array.isArray(next)) { - if (!Array.isArray(prev)) return false; - if (next.length !== prev.length) return false; - for (let i = 0; i < next.length; i++) { - if (!subsetEqual(prev[i], next[i])) return false; - } - return true; - } - - // objects: every provided key in `next` must match `prev` - for (const k of Object.keys(next)) { - const nv = next[k]; - if (typeof nv === 'undefined') continue; // treat "not provided" as no-op - const pv = prev[k]; - if (!subsetEqual(pv, nv)) return false; - } - return true; -} - -/** - * Delta between current and incoming authData with partial update semantics: - * - changed: providers truly changed (new or value differs on provided keys) - * - unlink: providers explicitly set to null (remove without validation) - * - unchanged: providers either absent in incoming or provided as matching subset - */ -const diffAuthData = (current = {}, incoming = {}) => { - const changed = {}; - const unlink = {}; - const unchanged = {}; - - const providers = new Set([...Object.keys(current), ...Object.keys(incoming)]); - for (const p of providers) { - const prev = current[p]; - const next = incoming[p]; - - if (next === null) { unlink[p] = true; continue; } - if (typeof next === 'undefined') { // provider untouched - if (typeof prev !== 'undefined') unchanged[p] = prev; - continue; - } - if (typeof prev === 'undefined') { // new provider - changed[p] = next; - continue; - } - - // key point: treat sanitized partial payload (subset) as unchanged - if (subsetEqual(prev, next)) { - unchanged[p] = prev; - } else { - changed[p] = next; - } - } - return { changed, unlink, unchanged }; -}; - module.exports = { Auth, master, @@ -683,6 +614,4 @@ module.exports = { hasMutatedAuthData, checkIfUserHasProvidedConfiguredProvidersForLogin, handleAuthDataValidation, - subsetEqual, - diffAuthData }; diff --git a/src/RestWrite.js b/src/RestWrite.js index 15dc9f0fde..78dd8c8878 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -155,24 +155,12 @@ RestWrite.prototype.execute = function () { return this.cleanUserAuthData(); }) .then(() => { - if (this.storage.returnUserWithSession) { - this.response = this.response || { - response: {}, - location: this.location(), - }; - - this.response.response = this.response.response || {}; - this.response.response.sessionToken = - this.response.response.sessionToken || this.auth.user?.getSessionToken?.(); - } - // Append the authDataResponse if exists if (this.authDataResponse) { if (this.response && this.response.response) { this.response.response.authDataResponse = this.authDataResponse; } } - if (this.storage.rejectSignup && this.config.preventSignupWithUnverifiedEmail) { throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); } @@ -535,11 +523,9 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () { key => this.data.authData[key] && this.data.authData[key].id ); - if (!hasAuthDataId) { - return; - } + if (!hasAuthDataId) { return; } - const r = await Auth.findUsersWithAuthData(this.config, this.data.authData, false); + const r = await Auth.findUsersWithAuthData(this.config, this.data.authData); const results = this.filteredObjectsByACL(r); if (results.length > 1) { throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); @@ -552,30 +538,17 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () { }; RestWrite.prototype.handleAuthData = async function (authData) { - let userId = this.getUserId(); - if (!userId && this.auth.isMaster) { - userId = this.query?.objectId || null; - } - - const isLogin = !userId; - - let authDataBeforeFindTriggered = false; - let usersResult = []; - if (userId) { - usersResult = await this.config.database.find('_User', { objectId: userId }); - } else { - usersResult = await Auth.findUsersWithAuthData(this.config, authData, true); - authDataBeforeFindTriggered = true; - } + const r = await Auth.findUsersWithAuthData(this.config, authData, true); + const results = this.filteredObjectsByACL(r); - const results = this.filteredObjectsByACL(usersResult); - const existingUser = results.length > 0 ? results[0] : null; - const foundUserIsNotCurrentUser = userId && existingUser && userId !== existingUser.objectId; + const userId = this.getUserId(); + const userResult = results[0]; + const foundUserIsNotCurrentUser = userId && userResult && userId !== userResult.objectId; if (results.length > 1 || foundUserIsNotCurrentUser) { // To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5 // Let's run some validation before throwing - await Auth.handleAuthDataValidation(authData, this, existingUser); + await Auth.handleAuthDataValidation(authData, this, userResult); throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } @@ -592,138 +565,90 @@ RestWrite.prototype.handleAuthData = async function (authData) { } // User found with provided authData - if (results.length !== 1 && !isLogin) { - return; - } + if (results.length === 1) { - const { changed, unlink, unchanged } = Auth.diffAuthData(existingUser.authData, authData); - const hasChanges = Object.keys(changed).length > 0; - const hasUnlink = Object.keys(unlink).length > 0; - const hasMutatedAuthData = hasChanges || hasUnlink; + this.storage.authProvider = Object.keys(authData).join(','); - const mutatedAuthData = {}; - const withoutUnlinked = {}; - for (const key of Object.keys(authData)) { - if (!unlink[key]) { - withoutUnlinked[key] = authData[key]; - } - - if (!unchanged[key]) { - mutatedAuthData[key] = authData[key]; - } - } - - if (!authDataBeforeFindTriggered && hasChanges) { - const r = await Auth.findUsersWithAuthData(this.config, changed, true); - const results = this.filteredObjectsByACL(r); - const foundUser = results.length > 0 ? results[0] : null; - const foundUserIsNotCurrentUser = userId && foundUser && userId !== foundUser.objectId; + const { hasMutatedAuthData, mutatedAuthData } = Auth.hasMutatedAuthData( + authData, + userResult.authData + ); - if (results.length > 1 || foundUserIsNotCurrentUser) { - // To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5 - // Let's run some validation before throwing - await Auth.handleAuthDataValidation(authData, this, existingUser); - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); - } - } + const isCurrentUserLoggedOrMaster = + (this.auth && this.auth.user && this.auth.user.id === userResult.objectId) || + this.auth.isMaster; - this.storage.authProvider = Object.keys(withoutUnlinked).join(','); + const isLogin = !userId; - const isCurrentUserLoggedOrMaster = - (this.auth && this.auth.user && this.auth.user.id === existingUser.objectId) || - this.auth.isMaster; + if (isLogin || isCurrentUserLoggedOrMaster) { + // no user making the call + // OR the user making the call is the right one + // Login with auth data + delete results[0].password; - if (isLogin || isCurrentUserLoggedOrMaster) { - // no user making the call - // OR the user making the call is the right one - // Login with auth data - delete results[0].password; + // need to set the objectId first otherwise location has trailing undefined + this.data.objectId = userResult.objectId; - this.storage.returnUserWithSession = true; - const authDataResponse = {}; - for (const key of Object.keys(withoutUnlinked)) { - if (withoutUnlinked[key] && withoutUnlinked[key].id) { - if (authData[key]) { - authDataResponse[key] = authData[key]; - } - authDataResponse[key] = authDataResponse[key] || {}; - authDataResponse[key].id = withoutUnlinked[key].id; + if (!this.query || !this.query.objectId) { + this.response = { + response: userResult, + location: this.location(), + }; + // Run beforeLogin hook before storing any updates + // to authData on the db; changes to userResult + // will be ignored. + await this.runBeforeLoginTrigger(deepcopy(userResult)); + + // If we are in login operation via authData + // we need to be sure that the user has provided + // required authData + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + { config: this.config, auth: this.auth }, + authData, + userResult.authData, + this.config + ); } - } - - // need to set the objectId first otherwise location has trailing undefined - this.data.objectId = existingUser.objectId; - if (!this.query || !this.query.objectId) { - this.response = { - response: existingUser, - location: this.location(), - }; - // Run beforeLogin hook before storing any updates - // to authData on the db; changes to existingUser - // will be ignored. - await this.runBeforeLoginTrigger(deepcopy(existingUser)); - - // If we are in login operation via authData - // we need to be sure that the user has provided - // required authData - Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( - { config: this.config, auth: this.auth }, - authData, - existingUser.authData, - this.config - ); - } - - if (!this.authDataResponse && !!Object.keys(authDataResponse).length) { - this.authDataResponse = authDataResponse; - } - - // Prevent validating if no mutated data detected on update - if (!hasMutatedAuthData && !isLogin) { - return; - } - - // Force to validate all provided authData on login - // on update only validate mutated ones - if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { - const res = await Auth.handleAuthDataValidation( - isLogin ? withoutUnlinked : changed, - this, - existingUser - ); + // Prevent validating if no mutated data detected on update + if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) { + return; + } - if (hasUnlink) { - for (const key of Object.keys(unlink)) { - res.authData[key] = null; - } + // Force to validate all provided authData on login + // on update only validate mutated ones + if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { + const res = await Auth.handleAuthDataValidation( + isLogin ? authData : mutatedAuthData, + this, + userResult + ); + this.data.authData = res.authData; + this.authDataResponse = res.authDataResponse; } - this.data.authData = res.authData; - this.authDataResponse = Object.keys(res.authDataResponse).length ? res.authDataResponse : undefined; - } + // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... + // we need to set it up there. + // We are supposed to have a response only on LOGIN with authData, so we skip those + // If we're not logging in, but just updating the current user, we can safely skip that part + if (this.response) { + // Assign the new authData in the response + Object.keys(mutatedAuthData).forEach(provider => { + this.response.response.authData[provider] = mutatedAuthData[provider]; + }); - // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... - // we need to set it up there. - // We are supposed to have a response only on LOGIN with authData, so we skip those - // If we're not logging in, but just updating the current user, we can safely skip that part - if (this.response) { - // Assign the new authData in the response - - const isEmpty = Object.keys(withoutUnlinked).length === 0; - this.response.response.authData = isEmpty ? undefined : withoutUnlinked; - - // Run the DB update directly, as 'master' only if authData contains some keys - // authData could not contains keys after validation if the authAdapter - // uses the `doNotSave` option. Just update the authData part - // Then we're good for the user, early exit of sorts - if (Object.keys(this.data.authData).length) { - await this.config.database.update( - this.className, - { objectId: this.data.objectId }, - { authData: this.data.authData }, - {} - ); + // Run the DB update directly, as 'master' only if authData contains some keys + // authData could not contains keys after validation if the authAdapter + // uses the `doNotSave` option. Just update the authData part + // Then we're good for the user, early exit of sorts + if (Object.keys(this.data.authData).length) { + await this.config.database.update( + this.className, + { objectId: this.data.objectId }, + { authData: this.data.authData }, + {} + ); + } } } } @@ -903,9 +828,7 @@ RestWrite.prototype._validateEmail = function () { }; RestWrite.prototype._validatePasswordPolicy = function () { - if (!this.config.passwordPolicy) { - return Promise.resolve(); - } + if (!this.config.passwordPolicy) { return Promise.resolve(); } return this._validatePasswordRequirements().then(() => { return this._validatePasswordHistory(); }); @@ -939,20 +862,18 @@ RestWrite.prototype._validatePasswordRequirements = function () { if (this.config.passwordPolicy.doNotAllowUsername === true) { if (this.data.username) { // username is not passed during password reset - if (this.data.password.indexOf(this.data.username) >= 0) { - return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError)); - } + if (this.data.password.indexOf(this.data.username) >= 0) + { return Promise.reject(new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError)); } } else { // retrieve the User object using objectId during password reset return this.config.database.find('_User', { objectId: this.objectId() }).then(results => { if (results.length != 1) { throw undefined; } - if (this.data.password.indexOf(results[0].username) >= 0) { - return Promise.reject( - new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError) - ); - } + if (this.data.password.indexOf(results[0].username) >= 0) + { return Promise.reject( + new Parse.Error(Parse.Error.VALIDATION_ERROR, containsUsernameError) + ); } return Promise.resolve(); }); } @@ -976,21 +897,19 @@ RestWrite.prototype._validatePasswordHistory = function () { } const user = results[0]; let oldPasswords = []; - if (user._password_history) { - oldPasswords = _.take( - user._password_history, - this.config.passwordPolicy.maxPasswordHistory - 1 - ); - } + if (user._password_history) + { oldPasswords = _.take( + user._password_history, + this.config.passwordPolicy.maxPasswordHistory - 1 + ); } oldPasswords.push(user.password); const newPassword = this.data.password; // compare the new password hash with all old password hashes const promises = oldPasswords.map(function (hash) { return passwordCrypto.compare(newPassword, hash).then(result => { - if (result) { - // reject if there is a match - return Promise.reject('REPEAT_PASSWORD'); - } + if (result) + // reject if there is a match + { return Promise.reject('REPEAT_PASSWORD'); } return Promise.resolve(); }); }); @@ -1000,15 +919,14 @@ RestWrite.prototype._validatePasswordHistory = function () { return Promise.resolve(); }) .catch(err => { - if (err === 'REPEAT_PASSWORD') { - // a match was found - return Promise.reject( - new Parse.Error( - Parse.Error.VALIDATION_ERROR, - `New password should not be the same as last ${this.config.passwordPolicy.maxPasswordHistory} passwords.` - ) - ); - } + if (err === 'REPEAT_PASSWORD') + // a match was found + { return Promise.reject( + new Parse.Error( + Parse.Error.VALIDATION_ERROR, + `New password should not be the same as last ${this.config.passwordPolicy.maxPasswordHistory} passwords.` + ) + ); } throw err; }); }); @@ -1042,16 +960,10 @@ RestWrite.prototype.createSessionTokenIfNeeded = async function () { // Get verification conditions which can be booleans or functions; the purpose of this async/await // structure is to avoid unnecessarily executing subsequent functions if previous ones fail in the // conditional statement below, as a developer may decide to execute expensive operations in them - const verifyUserEmails = async () => - this.config.verifyUserEmails === true || - (typeof this.config.verifyUserEmails === 'function' && - (await Promise.resolve(this.config.verifyUserEmails(request))) === true); - const preventLoginWithUnverifiedEmail = async () => - this.config.preventLoginWithUnverifiedEmail === true || - (typeof this.config.preventLoginWithUnverifiedEmail === 'function' && - (await Promise.resolve(this.config.preventLoginWithUnverifiedEmail(request))) === true); + const verifyUserEmails = async () => this.config.verifyUserEmails === true || (typeof this.config.verifyUserEmails === 'function' && await Promise.resolve(this.config.verifyUserEmails(request)) === true); + const preventLoginWithUnverifiedEmail = async () => this.config.preventLoginWithUnverifiedEmail === true || (typeof this.config.preventLoginWithUnverifiedEmail === 'function' && await Promise.resolve(this.config.preventLoginWithUnverifiedEmail(request)) === true); // If verification is required - if ((await verifyUserEmails()) && (await preventLoginWithUnverifiedEmail())) { + if (await verifyUserEmails() && await preventLoginWithUnverifiedEmail()) { this.storage.rejectSignup = true; return; } From 2979264f3a83d42b98639223a7fb2ee044684386 Mon Sep 17 00:00:00 2001 From: Aleksei Egovkin Date: Tue, 9 Sep 2025 01:34:48 +0200 Subject: [PATCH 3/4] Fix authData handling to exclude unlinked providers and add tests for edge cases --- spec/Users.authdata.spec.js | 156 ++++++++++++++++++++++++++++++++++++ src/RestWrite.js | 10 ++- 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 spec/Users.authdata.spec.js diff --git a/spec/Users.authdata.spec.js b/spec/Users.authdata.spec.js new file mode 100644 index 0000000000..6699688b79 --- /dev/null +++ b/spec/Users.authdata.spec.js @@ -0,0 +1,156 @@ +describe('RestWrite.handleAuthData', () => { + const MOCK_USER_ID = 'mockUserId'; + const MOCK_ACCESS_TOKEN = 'mockAccessToken123'; + + const createMockUser = () => ({ + id: MOCK_USER_ID, + code: 'C1', + }); + + const mockGooglePlayGamesAPI = () => { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: MOCK_ACCESS_TOKEN }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${MOCK_USER_ID}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: MOCK_USER_ID }), + }, + }, + ]); + }; + + const setupAuthConfig = (additionalProviders = {}) => { + return reconfigureServer({ + auth: { + gpgames: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + }, + someAdapter1: { + validateAuthData: () => Promise.resolve(), + validateAppId: () => Promise.resolve(), + validateOptions: () => {}, + }, + someAdapter2: { + validateAuthData: () => Promise.resolve(), + validateAppId: () => Promise.resolve(), + validateOptions: () => {}, + }, + ...additionalProviders, + }, + }); + }; + + beforeEach(async () => { + await setupAuthConfig(); + }); + + it('should handle multiple providers correctly', async () => { + mockGooglePlayGamesAPI(); + + const authData = { + gpgames: { id: MOCK_USER_ID, code: 'C4' }, + someAdapter2: { id: 'F1', access_token: 'fb_token' }, + }; + + const user = new Parse.User(); + user.set('authData', authData); + await user.save(); + + const sessionToken = user.getSessionToken(); + + await user.fetch({ sessionToken }); + const currentAuthData = user.get('authData') || {}; + + user.set('authData', { + someAdapter2: currentAuthData.someAdapter2, + someAdapter1: { id: 'T2', access_token: 'tw_token' }, + gpgames: null, // Unlink Google Play Games + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData') || {}; + + expect(finalAuthData.gpgames).toBeUndefined(); + expect(finalAuthData.someAdapter2?.id).toBe('F1'); + expect(finalAuthData.someAdapter1?.id).toBe('T2'); + }); + + it('should unlink provider via null', async () => { + mockGooglePlayGamesAPI(); + + const authData = createMockUser(); + const user = await Parse.User.logInWith('gpgames', { authData }); + const sessionToken = user.getSessionToken(); + + await user.fetch({ sessionToken }); + const currentAuthData = user.get('authData') || {}; + + user.set('authData', { + ...currentAuthData, + gpgames: null, + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData') || {}; + + expect(finalAuthData.gpgames).toBeUndefined(); + }); + + it('should handle empty authData gracefully', async () => { + mockGooglePlayGamesAPI(); + + const user = await Parse.User.signUp('test', 'password123'); + + const sessionToken = user.getSessionToken(); + await user.fetch({ sessionToken }); + + user.set('authData', { + someAdapter1: { id: 'T3', access_token: 'token456' }, + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData'); + + expect(finalAuthData).toBeDefined(); + expect(finalAuthData.someAdapter1?.id).toBe('T3'); + }); + + it('should handle partial provider data updates correctly', async () => { + mockGooglePlayGamesAPI(); + + const authData = createMockUser(); + const user = await Parse.User.logInWith('gpgames', { authData }); + + const sessionToken = user.getSessionToken(); + + await user.fetch({ sessionToken }); + + const currentAuthData = user.get('authData') || {}; + user.set('authData', { + ...currentAuthData, + gpgames: { + ...currentAuthData.gpgames, + code: 'new', + }, + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData'); + + expect(finalAuthData.gpgames.id).toBe(MOCK_USER_ID); + }); +}); diff --git a/src/RestWrite.js b/src/RestWrite.js index 78dd8c8878..871d8d767b 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -538,7 +538,15 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () { }; RestWrite.prototype.handleAuthData = async function (authData) { - const r = await Auth.findUsersWithAuthData(this.config, authData, true); + const withoutUnlinked = {}; + for (const provider of Object.keys(authData)) { + if (authData[provider] === null || authData[provider] === undefined) { + continue; + } + withoutUnlinked[provider] = authData[provider]; + } + + const r = await Auth.findUsersWithAuthData(this.config, withoutUnlinked, true); const results = this.filteredObjectsByACL(r); const userId = this.getUserId(); From 4e3ac64cbbf74a9ec5517cf5ada750e8ad609ba2 Mon Sep 17 00:00:00 2001 From: Aleksei Egovkin Date: Tue, 9 Sep 2025 02:12:09 +0200 Subject: [PATCH 4/4] remove redundant tests --- spec/RestWrite.handleAuthData.spec.js | 67 +++++++++++ spec/Users.authdata.spec.js | 156 -------------------------- 2 files changed, 67 insertions(+), 156 deletions(-) create mode 100644 spec/RestWrite.handleAuthData.spec.js delete mode 100644 spec/Users.authdata.spec.js diff --git a/spec/RestWrite.handleAuthData.spec.js b/spec/RestWrite.handleAuthData.spec.js new file mode 100644 index 0000000000..b9c677f43c --- /dev/null +++ b/spec/RestWrite.handleAuthData.spec.js @@ -0,0 +1,67 @@ +describe('RestWrite.handleAuthData', () => { + const MOCK_USER_ID = 'mockUserId'; + const MOCK_ACCESS_TOKEN = 'mockAccessToken123'; + + const createMockUser = () => ({ + id: MOCK_USER_ID, + code: 'C1', + }); + + const mockGooglePlayGamesAPI = () => { + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: MOCK_ACCESS_TOKEN }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${MOCK_USER_ID}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: MOCK_USER_ID }), + }, + }, + ]); + }; + + const setupAuthConfig = () => { + return reconfigureServer({ + auth: { + gpgames: { + clientId: 'validClientId', + clientSecret: 'validClientSecret', + } + }, + }); + }; + + beforeEach(async () => { + await setupAuthConfig(); + }); + + it('should unlink provider via null', async () => { + mockGooglePlayGamesAPI(); + + const authData = createMockUser(); + const user = await Parse.User.logInWith('gpgames', { authData }); + const sessionToken = user.getSessionToken(); + + await user.fetch({ sessionToken }); + const currentAuthData = user.get('authData') || {}; + + user.set('authData', { + ...currentAuthData, + gpgames: null, + }); + await user.save(null, { sessionToken }); + + const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + const finalAuthData = updatedUser.get('authData') || {}; + + expect(finalAuthData.gpgames).toBeUndefined(); + }); +}); diff --git a/spec/Users.authdata.spec.js b/spec/Users.authdata.spec.js deleted file mode 100644 index 6699688b79..0000000000 --- a/spec/Users.authdata.spec.js +++ /dev/null @@ -1,156 +0,0 @@ -describe('RestWrite.handleAuthData', () => { - const MOCK_USER_ID = 'mockUserId'; - const MOCK_ACCESS_TOKEN = 'mockAccessToken123'; - - const createMockUser = () => ({ - id: MOCK_USER_ID, - code: 'C1', - }); - - const mockGooglePlayGamesAPI = () => { - mockFetch([ - { - url: 'https://oauth2.googleapis.com/token', - method: 'POST', - response: { - ok: true, - json: () => Promise.resolve({ access_token: MOCK_ACCESS_TOKEN }), - }, - }, - { - url: `https://www.googleapis.com/games/v1/players/${MOCK_USER_ID}`, - method: 'GET', - response: { - ok: true, - json: () => Promise.resolve({ playerId: MOCK_USER_ID }), - }, - }, - ]); - }; - - const setupAuthConfig = (additionalProviders = {}) => { - return reconfigureServer({ - auth: { - gpgames: { - clientId: 'validClientId', - clientSecret: 'validClientSecret', - }, - someAdapter1: { - validateAuthData: () => Promise.resolve(), - validateAppId: () => Promise.resolve(), - validateOptions: () => {}, - }, - someAdapter2: { - validateAuthData: () => Promise.resolve(), - validateAppId: () => Promise.resolve(), - validateOptions: () => {}, - }, - ...additionalProviders, - }, - }); - }; - - beforeEach(async () => { - await setupAuthConfig(); - }); - - it('should handle multiple providers correctly', async () => { - mockGooglePlayGamesAPI(); - - const authData = { - gpgames: { id: MOCK_USER_ID, code: 'C4' }, - someAdapter2: { id: 'F1', access_token: 'fb_token' }, - }; - - const user = new Parse.User(); - user.set('authData', authData); - await user.save(); - - const sessionToken = user.getSessionToken(); - - await user.fetch({ sessionToken }); - const currentAuthData = user.get('authData') || {}; - - user.set('authData', { - someAdapter2: currentAuthData.someAdapter2, - someAdapter1: { id: 'T2', access_token: 'tw_token' }, - gpgames: null, // Unlink Google Play Games - }); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData') || {}; - - expect(finalAuthData.gpgames).toBeUndefined(); - expect(finalAuthData.someAdapter2?.id).toBe('F1'); - expect(finalAuthData.someAdapter1?.id).toBe('T2'); - }); - - it('should unlink provider via null', async () => { - mockGooglePlayGamesAPI(); - - const authData = createMockUser(); - const user = await Parse.User.logInWith('gpgames', { authData }); - const sessionToken = user.getSessionToken(); - - await user.fetch({ sessionToken }); - const currentAuthData = user.get('authData') || {}; - - user.set('authData', { - ...currentAuthData, - gpgames: null, - }); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData') || {}; - - expect(finalAuthData.gpgames).toBeUndefined(); - }); - - it('should handle empty authData gracefully', async () => { - mockGooglePlayGamesAPI(); - - const user = await Parse.User.signUp('test', 'password123'); - - const sessionToken = user.getSessionToken(); - await user.fetch({ sessionToken }); - - user.set('authData', { - someAdapter1: { id: 'T3', access_token: 'token456' }, - }); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData'); - - expect(finalAuthData).toBeDefined(); - expect(finalAuthData.someAdapter1?.id).toBe('T3'); - }); - - it('should handle partial provider data updates correctly', async () => { - mockGooglePlayGamesAPI(); - - const authData = createMockUser(); - const user = await Parse.User.logInWith('gpgames', { authData }); - - const sessionToken = user.getSessionToken(); - - await user.fetch({ sessionToken }); - - const currentAuthData = user.get('authData') || {}; - user.set('authData', { - ...currentAuthData, - gpgames: { - ...currentAuthData.gpgames, - code: 'new', - }, - }); - await user.save(null, { sessionToken }); - - const updatedUser = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const finalAuthData = updatedUser.get('authData'); - - expect(finalAuthData.gpgames.id).toBe(MOCK_USER_ID); - }); -});