diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 7301ab54c1..e7bde12239 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -76,6 +76,41 @@ describe('Auth Adapter features', () => { validateAppId: () => Promise.resolve(), }; + // Code-based adapter that requires 'code' field (like gpgames) + const codeBasedAdapter = { + validateAppId: () => Promise.resolve(), + validateSetUp: authData => { + if (!authData.code) { + throw new Error('code is required.'); + } + return Promise.resolve({ save: { id: authData.id } }); + }, + validateUpdate: authData => { + if (!authData.code) { + throw new Error('code is required.'); + } + return Promise.resolve({ save: { id: authData.id } }); + }, + validateLogin: authData => { + if (!authData.code) { + throw new Error('code is required.'); + } + return Promise.resolve({ save: { id: authData.id } }); + }, + afterFind: authData => { + // Strip sensitive 'code' field when returning to client + return { id: authData.id }; + }, + }; + + // Simple adapter that doesn't require code + const simpleAdapter = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'test', @@ -1302,4 +1337,42 @@ describe('Auth Adapter features', () => { await user.fetch({ useMasterKey: true }); expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } }); }); + + it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { + await reconfigureServer({ + auth: { + codeBasedAdapter, + simpleAdapter, + }, + }); + + // Login with code-based provider + const user = new Parse.User(); + await user.save({ authData: { codeBasedAdapter: { id: 'user1', code: 'code1' } } }); + const sessionToken = user.getSessionToken(); + await user.fetch({ sessionToken }); + + // At this point, authData.codeBasedAdapter only has {id: 'user1'} due to afterFind + const current = user.get('authData') || {}; + expect(current.codeBasedAdapter).toEqual({ id: 'user1' }); + + // Add a second provider while keeping the first unchanged + user.set('authData', { + ...current, + simpleAdapter: { id: 'simple1' }, + // codeBasedAdapter is NOT modified (no new code provided) + }); + + // This should succeed without requiring 'code' for codeBasedAdapter + await user.save(null, { sessionToken }); + + // Verify both providers are present + const reloaded = await new Parse.Query(Parse.User).get(user.id, { + useMasterKey: true, + }); + + const authData = reloaded.get('authData') || {}; + expect(authData.simpleAdapter && authData.simpleAdapter.id).toBe('simple1'); + expect(authData.codeBasedAdapter && authData.codeBasedAdapter.id).toBe('user1'); + }); }); diff --git a/src/Auth.js b/src/Auth.js index d8bf7e651f..0601151ca4 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -456,7 +456,32 @@ const hasMutatedAuthData = (authData, userAuthData) => { if (provider === 'anonymous') { return; } const providerData = authData[provider]; const userProviderAuthData = userAuthData[provider]; - if (!isDeepStrictEqual(providerData, userProviderAuthData)) { + + // If unlinking (setting to null), consider it mutated + if (providerData === null) { + mutatedAuthData[provider] = providerData; + return; + } + + // If provider doesn't exist in stored data, it's new + if (!userProviderAuthData) { + mutatedAuthData[provider] = providerData; + return; + } + + // Check if incoming data represents actual changes vs just echoing back + // what afterFind returned. If incoming data is a subset of stored data + // (all incoming fields match stored values), it's not mutated. + // If incoming data has different values or fields not in stored data, it's mutated. + // This handles the case where afterFind strips sensitive fields like 'code': + // - Incoming: { id: 'x' }, Stored: { id: 'x', code: 'secret' } -> NOT mutated (subset) + // - Incoming: { id: 'x', token: 'new' }, Stored: { id: 'x', token: 'old' } -> MUTATED + const incomingKeys = Object.keys(providerData || {}); + const hasChanges = incomingKeys.some(key => { + return !isDeepStrictEqual(providerData[key], userProviderAuthData[key]); + }); + + if (hasChanges) { mutatedAuthData[provider] = providerData; } });