From 496586dbaae51c15346574614143a062287db510 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:45:53 +0000 Subject: [PATCH 01/17] fix --- spec/AuthenticationAdaptersV2.spec.js | 55 +++++++++++++++++++++++++++ src/Auth.js | 5 +++ 2 files changed, 60 insertions(+) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index e7bde12239..ac03628eba 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1338,6 +1338,61 @@ describe('Auth Adapter features', () => { expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } }); }); + it('should unlink a code-based auth provider without triggering adapter validation', async () => { + const mockUserId = 'gpgamesUser123'; + const mockAccessToken = 'mockAccessToken'; + + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: mockAccessToken }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${mockUserId}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: mockUserId }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + }, + }); + + // Sign up with gpgames code-based provider + const user = new Parse.User(); + await user.save({ + authData: { + gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' }, + }, + }); + const sessionToken = user.getSessionToken(); + + // Reset fetch spy to track calls during unlink + global.fetch.calls.reset(); + + // Unlink by setting authData to null; should not call beforeFind / external APIs + await user.save({ authData: { gpgames: null } }, { sessionToken }); + + // No external HTTP calls should have been made during unlink + expect(global.fetch.calls.count()).toBe(0); + + // Verify provider was removed + const reloaded = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); + expect((reloaded.get('authData') || {}).gpgames).toBeUndefined(); + }); + it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { await reconfigureServer({ auth: { diff --git a/src/Auth.js b/src/Auth.js index 0601151ca4..575a850989 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -424,6 +424,11 @@ const findUsersWithAuthData = async (config, authData, beforeFind) => { providers.map(async provider => { const providerAuthData = authData[provider]; + // Skip providers being unlinked (null value) + if (providerAuthData === null) { + return null; + } + const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; if (beforeFind && typeof adapter?.beforeFind === 'function') { await adapter.beforeFind(providerAuthData); From e46c56208291f0723b90a9c2e39fa376d024d3f2 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:52:35 +0000 Subject: [PATCH 02/17] test expansion --- spec/AuthenticationAdaptersV2.spec.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index ac03628eba..1914535114 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1342,6 +1342,11 @@ describe('Auth Adapter features', () => { const mockUserId = 'gpgamesUser123'; const mockAccessToken = 'mockAccessToken'; + const otherAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + mockFetch([ { url: 'https://oauth2.googleapis.com/token', @@ -1367,14 +1372,16 @@ describe('Auth Adapter features', () => { clientId: 'testClientId', clientSecret: 'testClientSecret', }, + otherAdapter, }, }); - // Sign up with gpgames code-based provider + // Sign up with gpgames code-based provider and a second provider const user = new Parse.User(); await user.save({ authData: { gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' }, + otherAdapter: { id: 'other1' }, }, }); const sessionToken = user.getSessionToken(); @@ -1382,15 +1389,18 @@ describe('Auth Adapter features', () => { // Reset fetch spy to track calls during unlink global.fetch.calls.reset(); - // Unlink by setting authData to null; should not call beforeFind / external APIs + // Unlink gpgames by setting authData to null; should not call beforeFind / external APIs await user.save({ authData: { gpgames: null } }, { sessionToken }); // No external HTTP calls should have been made during unlink expect(global.fetch.calls.count()).toBe(0); - // Verify provider was removed + // Verify gpgames was removed while the other provider remains const reloaded = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - expect((reloaded.get('authData') || {}).gpgames).toBeUndefined(); + const authData = reloaded.get('authData'); + expect(authData).toBeDefined(); + expect(authData.gpgames).toBeUndefined(); + expect(authData.otherAdapter).toEqual({ id: 'other1' }); }); it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { From 115d991ab4ce7c85cf3d804f2008ee14e8ae5812 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:04:47 +0000 Subject: [PATCH 03/17] postgres --- spec/AuthenticationAdaptersV2.spec.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 1914535114..9740441dee 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1376,28 +1376,33 @@ describe('Auth Adapter features', () => { }, }); - // Sign up with gpgames code-based provider and a second provider + // Sign up with username/password, then link providers const user = new Parse.User(); + await user.signUp({ username: 'gpgamesTestUser', password: 'password123' }); + + // Link gpgames code-based provider await user.save({ authData: { gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' }, - otherAdapter: { id: 'other1' }, }, }); - const sessionToken = user.getSessionToken(); + + // Link a second provider + await user.save({ authData: { otherAdapter: { id: 'other1' } } }); // Reset fetch spy to track calls during unlink global.fetch.calls.reset(); // Unlink gpgames by setting authData to null; should not call beforeFind / external APIs + const sessionToken = user.getSessionToken(); await user.save({ authData: { gpgames: null } }, { sessionToken }); // No external HTTP calls should have been made during unlink expect(global.fetch.calls.count()).toBe(0); // Verify gpgames was removed while the other provider remains - const reloaded = await new Parse.Query(Parse.User).get(user.id, { useMasterKey: true }); - const authData = reloaded.get('authData'); + await user.fetch({ useMasterKey: true }); + const authData = user.get('authData'); expect(authData).toBeDefined(); expect(authData.gpgames).toBeUndefined(); expect(authData.otherAdapter).toEqual({ id: 'other1' }); From a6c1a7e928d686d8d9988404450f0253b02db9f6 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:26:09 +0000 Subject: [PATCH 04/17] fix echo back unchanged provider data alongside null (unlink) --- spec/AuthenticationAdaptersV2.spec.js | 96 ++++++++++++++++++++++++ src/Adapters/Auth/BaseCodeAuthAdapter.js | 7 ++ 2 files changed, 103 insertions(+) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 9740441dee..ae13a76ce9 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1408,6 +1408,102 @@ describe('Auth Adapter features', () => { expect(authData.otherAdapter).toEqual({ id: 'other1' }); }); + fit('should unlink one code-based provider while echoing back another unchanged', async () => { + const gpgamesUserId = 'gpgamesUser1'; + const instagramUserId = 'igUser1'; + + // Mock gpgames API for initial login + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'gpgamesToken' }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${gpgamesUserId}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: gpgamesUserId }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + instagram: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + redirectUri: 'https://example.com/callback', + }, + }, + }); + + // Login with gpgames + const user = await Parse.User.logInWith('gpgames', { + authData: { id: gpgamesUserId, code: 'gpCode1' }, + }); + const sessionToken = user.getSessionToken(); + + // Mock instagram API for linking + mockFetch([ + { + url: 'https://api.instagram.com/oauth/access_token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: 'igToken' }), + }, + }, + { + url: `https://graph.instagram.com/me?fields=id&access_token=igToken`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ id: instagramUserId }), + }, + }, + ]); + + // Link instagram as second provider + await user.save( + { authData: { instagram: { id: instagramUserId, code: 'igCode1' } } }, + { sessionToken } + ); + + // Fetch to get current authData (afterFind strips credentials, leaving only { id }) + await user.fetch({ sessionToken }); + const currentAuthData = user.get('authData'); + expect(currentAuthData.gpgames).toBeDefined(); + expect(currentAuthData.instagram).toBeDefined(); + + // Reset fetch spy + global.fetch.calls.reset(); + + // Unlink gpgames while echoing back instagram unchanged — the common client pattern: + // fetch current state, spread it, set the one to unlink to null + user.set('authData', { ...currentAuthData, gpgames: null }); + await user.save(null, { sessionToken }); + + // No external HTTP calls during unlink (no code exchange for unchanged instagram) + expect(global.fetch.calls.count()).toBe(0); + + // Verify gpgames removed, instagram preserved + await user.fetch({ useMasterKey: true }); + const finalAuthData = user.get('authData'); + expect(finalAuthData).toBeDefined(); + expect(finalAuthData.gpgames).toBeUndefined(); + expect(finalAuthData.instagram).toBeDefined(); + expect(finalAuthData.instagram.id).toBe(instagramUserId); + }); + it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { await reconfigureServer({ auth: { diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index 696e4ee71b..b3f7b846e0 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -29,6 +29,13 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } async beforeFind(authData) { + // If id is already resolved and no credentials are provided, there's nothing + // to process. This handles echoed-back authData from afterFind during updates, + // e.g. when a client fetches authData { id: '...' } and sends it back unchanged. + if (authData?.id && !authData?.code && !authData?.access_token) { + return; + } + if (this.enableInsecureAuth && !authData?.code) { if (!authData?.access_token) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); From 6f42d4d7dae5dd08dc7a92e17a6f98cd65da8a4a Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:30:46 +0000 Subject: [PATCH 05/17] unfit --- spec/AuthenticationAdaptersV2.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index ae13a76ce9..59da5ab338 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1408,7 +1408,7 @@ describe('Auth Adapter features', () => { expect(authData.otherAdapter).toEqual({ id: 'other1' }); }); - fit('should unlink one code-based provider while echoing back another unchanged', async () => { + it('should unlink one code-based provider while echoing back another unchanged', async () => { const gpgamesUserId = 'gpgamesUser1'; const instagramUserId = 'igUser1'; From 7a0331d3fac1506de6b0927f07cd448df21c3494 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Feb 2026 03:34:10 +0000 Subject: [PATCH 06/17] mock nitpick --- spec/AuthenticationAdaptersV2.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 59da5ab338..a341a4ed08 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1448,7 +1448,7 @@ describe('Auth Adapter features', () => { // Login with gpgames const user = await Parse.User.logInWith('gpgames', { - authData: { id: gpgamesUserId, code: 'gpCode1' }, + authData: { id: gpgamesUserId, code: 'gpCode1', redirect_uri: 'https://example.com/callback' }, }); const sessionToken = user.getSessionToken(); From 6b5b9a81c550fd46d625c9e91feabc0260b2f468 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:48:24 +0000 Subject: [PATCH 07/17] fix --- spec/Adapters/Auth/BaseCodeAdapter.spec.js | 21 ++++++++++++++++++--- src/Adapters/Auth/BaseCodeAuthAdapter.js | 19 +++++++++---------- src/Auth.js | 8 +++++++- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/spec/Adapters/Auth/BaseCodeAdapter.spec.js b/spec/Adapters/Auth/BaseCodeAdapter.spec.js index fef4b43306..d40458512a 100644 --- a/spec/Adapters/Auth/BaseCodeAdapter.spec.js +++ b/spec/Adapters/Auth/BaseCodeAdapter.spec.js @@ -150,18 +150,28 @@ describe('BaseAuthCodeAdapter', function () { describe('validateLogin', function () { it('should return user id from authData', function () { - const authData = { id: 'validUserId' }; + const authData = { id: 'validUserId', access_token: 'validAccessToken' }; const result = adapter.validateLogin(authData); expect(result).toEqual({ id: 'validUserId' }); }); + + it('should throw if access_token is missing', function () { + const authData = { id: 'validUserId' }; + expect(() => adapter.validateLogin(authData)).toThrowError('TestAdapter code is required.'); + }); }); describe('validateSetUp', function () { it('should return user id from authData', function () { - const authData = { id: 'validUserId' }; + const authData = { id: 'validUserId', access_token: 'validAccessToken' }; const result = adapter.validateSetUp(authData); expect(result).toEqual({ id: 'validUserId' }); }); + + it('should throw if access_token is missing', function () { + const authData = { id: 'validUserId' }; + expect(() => adapter.validateSetUp(authData)).toThrowError('TestAdapter code is required.'); + }); }); describe('afterFind', function () { @@ -174,9 +184,14 @@ describe('BaseAuthCodeAdapter', function () { describe('validateUpdate', function () { it('should return user id from authData', function () { - const authData = { id: 'validUserId' }; + const authData = { id: 'validUserId', access_token: 'validAccessToken' }; const result = adapter.validateUpdate(authData); expect(result).toEqual({ id: 'validUserId' }); }); + + it('should throw if access_token is missing', function () { + const authData = { id: 'validUserId' }; + expect(() => adapter.validateUpdate(authData)).toThrowError('TestAdapter code is required.'); + }); }); }); diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index b3f7b846e0..701c3e830e 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -29,13 +29,6 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } async beforeFind(authData) { - // If id is already resolved and no credentials are provided, there's nothing - // to process. This handles echoed-back authData from afterFind during updates, - // e.g. when a client fetches authData { id: '...' } and sends it back unchanged. - if (authData?.id && !authData?.code && !authData?.access_token) { - return; - } - if (this.enableInsecureAuth && !authData?.code) { if (!authData?.access_token) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`); @@ -80,14 +73,18 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } validateLogin(authData) { - // User validation is already done in beforeFind + if (!authData?.access_token) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); + } return { id: authData.id, } } validateSetUp(authData) { - // User validation is already done in beforeFind + if (!authData?.access_token) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); + } return { id: authData.id, } @@ -100,7 +97,9 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } validateUpdate(authData) { - // User validation is already done in beforeFind + if (!authData?.access_token) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); + } return { id: authData.id, } diff --git a/src/Auth.js b/src/Auth.js index 575a850989..fdb7adfc3c 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -429,8 +429,14 @@ const findUsersWithAuthData = async (config, authData, beforeFind) => { return null; } + // Skip beforeFind when authData has no credentials to process (only id or empty). + // This handles echoed-back authData from afterFind during updates, e.g. when a + // client sends back unchanged provider data alongside a provider unlink. + const providerKeys = Object.keys(providerAuthData || {}); + const hasCredentials = providerKeys.some(key => key !== 'id'); + const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; - if (beforeFind && typeof adapter?.beforeFind === 'function') { + if (beforeFind && hasCredentials && typeof adapter?.beforeFind === 'function') { await adapter.beforeFind(providerAuthData); } From 52131b15aecaa9ff3f0ce3dc0d1c4559a251f6c6 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:37:56 +0000 Subject: [PATCH 08/17] fix postgres unlinking --- .../Postgres/PostgresStorageAdapter.js | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 7eaafcbde2..e988c9cc19 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1607,20 +1607,28 @@ export class PostgresStorageAdapter implements StorageAdapter { const generate = (jsonb: string, key: string, value: any) => { return `json_object_set_key(COALESCE(${jsonb}, '{}'::jsonb), ${key}, ${value})::jsonb`; }; + const generateRemove = (jsonb: string, key: string) => { + return `(COALESCE(${jsonb}, '{}'::jsonb) - ${key})`; + }; const lastKey = `$${index}:name`; const fieldNameIndex = index; index += 1; values.push(fieldName); const update = Object.keys(fieldValue).reduce((lastKey: string, key: string) => { + let value = fieldValue[key]; + if (value && value.__op === 'Delete') { + value = null; + } + if (value === null) { + const str = generateRemove(lastKey, `$${index}::text`); + values.push(key); + index += 1; + return str; + } const str = generate(lastKey, `$${index}::text`, `$${index + 1}::jsonb`); index += 2; - let value = fieldValue[key]; if (value) { - if (value.__op === 'Delete') { - value = null; - } else { - value = JSON.stringify(value); - } + value = JSON.stringify(value); } values.push(key, value); return str; From 3814f5df231c775a285b5ef220a8a67f2cc8f6f0 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:13:35 +0000 Subject: [PATCH 09/17] revert breaking changes for custom adapters --- spec/Adapters/Auth/BaseCodeAdapter.spec.js | 21 +++------------------ src/Adapters/Auth/BaseCodeAuthAdapter.js | 10 ---------- src/Auth.js | 13 +++++++------ src/RestWrite.js | 3 ++- 4 files changed, 12 insertions(+), 35 deletions(-) diff --git a/spec/Adapters/Auth/BaseCodeAdapter.spec.js b/spec/Adapters/Auth/BaseCodeAdapter.spec.js index d40458512a..fef4b43306 100644 --- a/spec/Adapters/Auth/BaseCodeAdapter.spec.js +++ b/spec/Adapters/Auth/BaseCodeAdapter.spec.js @@ -150,28 +150,18 @@ describe('BaseAuthCodeAdapter', function () { describe('validateLogin', function () { it('should return user id from authData', function () { - const authData = { id: 'validUserId', access_token: 'validAccessToken' }; + const authData = { id: 'validUserId' }; const result = adapter.validateLogin(authData); expect(result).toEqual({ id: 'validUserId' }); }); - - it('should throw if access_token is missing', function () { - const authData = { id: 'validUserId' }; - expect(() => adapter.validateLogin(authData)).toThrowError('TestAdapter code is required.'); - }); }); describe('validateSetUp', function () { it('should return user id from authData', function () { - const authData = { id: 'validUserId', access_token: 'validAccessToken' }; + const authData = { id: 'validUserId' }; const result = adapter.validateSetUp(authData); expect(result).toEqual({ id: 'validUserId' }); }); - - it('should throw if access_token is missing', function () { - const authData = { id: 'validUserId' }; - expect(() => adapter.validateSetUp(authData)).toThrowError('TestAdapter code is required.'); - }); }); describe('afterFind', function () { @@ -184,14 +174,9 @@ describe('BaseAuthCodeAdapter', function () { describe('validateUpdate', function () { it('should return user id from authData', function () { - const authData = { id: 'validUserId', access_token: 'validAccessToken' }; + const authData = { id: 'validUserId' }; const result = adapter.validateUpdate(authData); expect(result).toEqual({ id: 'validUserId' }); }); - - it('should throw if access_token is missing', function () { - const authData = { id: 'validUserId' }; - expect(() => adapter.validateUpdate(authData)).toThrowError('TestAdapter code is required.'); - }); }); }); diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index 701c3e830e..ad33d56050 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -73,18 +73,12 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } validateLogin(authData) { - if (!authData?.access_token) { - throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); - } return { id: authData.id, } } validateSetUp(authData) { - if (!authData?.access_token) { - throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); - } return { id: authData.id, } @@ -97,13 +91,9 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } validateUpdate(authData) { - if (!authData?.access_token) { - throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); - } return { id: authData.id, } - } parseResponseData(data) { diff --git a/src/Auth.js b/src/Auth.js index fdb7adfc3c..b7cffa8aac 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -417,7 +417,7 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer }); }; -const findUsersWithAuthData = async (config, authData, beforeFind) => { +const findUsersWithAuthData = async (config, authData, beforeFind, isUpdate) => { const providers = Object.keys(authData); const queries = await Promise.all( @@ -429,15 +429,16 @@ const findUsersWithAuthData = async (config, authData, beforeFind) => { return null; } - // Skip beforeFind when authData has no credentials to process (only id or empty). - // This handles echoed-back authData from afterFind during updates, e.g. when a - // client sends back unchanged provider data alongside a provider unlink. const providerKeys = Object.keys(providerAuthData || {}); const hasCredentials = providerKeys.some(key => key !== 'id'); + // On update, skip beforeFind for echoed-back authData (no credentials to process). + // On login/signup, always call beforeFind so it can reject missing credentials. const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; - if (beforeFind && hasCredentials && typeof adapter?.beforeFind === 'function') { - await adapter.beforeFind(providerAuthData); + if (beforeFind && typeof adapter?.beforeFind === 'function') { + if (hasCredentials || !isUpdate) { + await adapter.beforeFind(providerAuthData); + } } if (!providerAuthData?.id) { diff --git a/src/RestWrite.js b/src/RestWrite.js index 6630a81e85..b9a9ae1674 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -541,7 +541,8 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () { }; RestWrite.prototype.handleAuthData = async function (authData) { - const r = await Auth.findUsersWithAuthData(this.config, authData, true); + const isUpdate = !!this.query; + const r = await Auth.findUsersWithAuthData(this.config, authData, true, isUpdate); const results = this.filteredObjectsByACL(r); const userId = this.getUserId(); From 965fc6269b056a084b56852240e307d735818ac8 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:20:10 +0000 Subject: [PATCH 10/17] docs --- src/Adapters/Auth/BaseCodeAuthAdapter.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index ad33d56050..4e752f26bd 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -72,24 +72,45 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { throw new Error('getAccessTokenFromCode is not implemented'); } + /** + * Validates auth data on login. No credential check is needed as `beforeFind` + * already validated credentials before this method is called. `beforeFind` + * exchanges the auth code for an access_token and verifies user identity; it + * rejects requests with missing credentials before these methods are called. + */ validateLogin(authData) { return { id: authData.id, } } + /** + * Validates auth data on first setup. No credential check is needed as `beforeFind` + * already validated credentials before this method is called. `beforeFind` + * exchanges the auth code for an access_token and verifies user identity; it + * rejects requests with missing credentials before these methods are called. + */ validateSetUp(authData) { return { id: authData.id, } } + /** + * Returns the auth data to expose to the client after a query. + */ afterFind(authData) { return { id: authData.id, } } + /** + * Validates auth data on update. No credential check is needed as `beforeFind` + * already validated credentials before this method is called. `beforeFind` + * exchanges the auth code for an access_token and verifies user identity; it + * rejects requests with missing credentials before these methods are called. + */ validateUpdate(authData) { return { id: authData.id, From a741548e5255b6ca263087019a6580fe4001b291 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:02:08 +0000 Subject: [PATCH 11/17] docs --- src/Adapters/Auth/BaseCodeAuthAdapter.js | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index 4e752f26bd..139fcf22f4 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -73,10 +73,9 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } /** - * Validates auth data on login. No credential check is needed as `beforeFind` - * already validated credentials before this method is called. `beforeFind` - * exchanges the auth code for an access_token and verifies user identity; it - * rejects requests with missing credentials before these methods are called. + * Validates auth data on login. On login, `beforeFind` always runs first and + * validates credentials (exchanges code for access_token, verifies identity), + * so no additional credential check is needed here. */ validateLogin(authData) { return { @@ -85,10 +84,8 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } /** - * Validates auth data on first setup. No credential check is needed as `beforeFind` - * already validated credentials before this method is called. `beforeFind` - * exchanges the auth code for an access_token and verifies user identity; it - * rejects requests with missing credentials before these methods are called. + * Validates auth data on first setup. On signup, `beforeFind` always runs first + * and validates credentials, so no additional credential check is needed here. */ validateSetUp(authData) { return { @@ -106,10 +103,11 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } /** - * Validates auth data on update. No credential check is needed as `beforeFind` - * already validated credentials before this method is called. `beforeFind` - * exchanges the auth code for an access_token and verifies user identity; it - * rejects requests with missing credentials before these methods are called. + * Validates auth data on update. On update, `beforeFind` is skipped when + * authData has no credentials (e.g. echoed-back `{ id }` from `afterFind`). + * Non-mutated echoed-back data is not validated at all. Mutated data without + * credentials (e.g. a changed `id`) will reach this method without prior + * `beforeFind` validation. */ validateUpdate(authData) { return { From 1d3ea7017f46393e02010869a71d66ed5acc46bc Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:21:08 +0000 Subject: [PATCH 12/17] reject change provider ID without credentials --- spec/Adapters/Auth/BaseCodeAdapter.spec.js | 9 +++- spec/AuthenticationAdaptersV2.spec.js | 49 ++++++++++++++++++++++ src/Adapters/Auth/BaseCodeAuthAdapter.js | 5 ++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/spec/Adapters/Auth/BaseCodeAdapter.spec.js b/spec/Adapters/Auth/BaseCodeAdapter.spec.js index fef4b43306..a629ddad58 100644 --- a/spec/Adapters/Auth/BaseCodeAdapter.spec.js +++ b/spec/Adapters/Auth/BaseCodeAdapter.spec.js @@ -173,10 +173,15 @@ describe('BaseAuthCodeAdapter', function () { }); describe('validateUpdate', function () { - it('should return user id from authData', function () { - const authData = { id: 'validUserId' }; + it('should return user id from authData with access_token', function () { + const authData = { id: 'validUserId', access_token: 'validAccessToken' }; const result = adapter.validateUpdate(authData); expect(result).toEqual({ id: 'validUserId' }); }); + + it('should throw if access_token is missing', function () { + const authData = { id: 'validUserId' }; + expect(() => adapter.validateUpdate(authData)).toThrowError('TestAdapter code is required.'); + }); }); }); diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index a341a4ed08..023ebd7c9b 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1504,6 +1504,55 @@ describe('Auth Adapter features', () => { expect(finalAuthData.instagram.id).toBe(instagramUserId); }); + it('should reject updating a code-based provider with only an id and no credentials', async () => { + const mockUserId = 'gpgamesUser123'; + const mockAccessToken = 'mockAccessToken'; + + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: mockAccessToken }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${mockUserId}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: mockUserId }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + }, + }); + + // Sign up and link gpgames with valid credentials + const user = new Parse.User(); + await user.save({ + authData: { + gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' }, + }, + }); + const sessionToken = user.getSessionToken(); + + // Attempt to change gpgames id without credentials (no code or access_token) + await expectAsync( + user.save({ authData: { gpgames: { id: 'differentUserId' } } }, { sessionToken }) + ).toBeRejectedWith( + jasmine.objectContaining({ message: jasmine.stringContaining('code is required') }) + ); + }); + it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { await reconfigureServer({ auth: { diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index 139fcf22f4..5346c05b55 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -107,9 +107,12 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { * authData has no credentials (e.g. echoed-back `{ id }` from `afterFind`). * Non-mutated echoed-back data is not validated at all. Mutated data without * credentials (e.g. a changed `id`) will reach this method without prior - * `beforeFind` validation. + * `beforeFind` validation, so an explicit credential check is required here. */ validateUpdate(authData) { + if (!authData?.access_token) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); + } return { id: authData.id, } From d87503993545c48d197589c91fb2ba4370b989e3 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:31:07 +0000 Subject: [PATCH 13/17] reject linking a new code-based provider with only an id and no credentials --- spec/Adapters/Auth/BaseCodeAdapter.spec.js | 9 +++++++-- spec/AuthenticationAdaptersV2.spec.js | 23 ++++++++++++++++++++++ src/Adapters/Auth/BaseCodeAuthAdapter.js | 7 ++++++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/spec/Adapters/Auth/BaseCodeAdapter.spec.js b/spec/Adapters/Auth/BaseCodeAdapter.spec.js index a629ddad58..231b8b0e03 100644 --- a/spec/Adapters/Auth/BaseCodeAdapter.spec.js +++ b/spec/Adapters/Auth/BaseCodeAdapter.spec.js @@ -157,11 +157,16 @@ describe('BaseAuthCodeAdapter', function () { }); describe('validateSetUp', function () { - it('should return user id from authData', function () { - const authData = { id: 'validUserId' }; + it('should return user id from authData with access_token', function () { + const authData = { id: 'validUserId', access_token: 'validAccessToken' }; const result = adapter.validateSetUp(authData); expect(result).toEqual({ id: 'validUserId' }); }); + + it('should throw if access_token is missing', function () { + const authData = { id: 'validUserId' }; + expect(() => adapter.validateSetUp(authData)).toThrowError('TestAdapter code is required.'); + }); }); describe('afterFind', function () { diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 023ebd7c9b..1cf2c77c2b 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1553,6 +1553,29 @@ describe('Auth Adapter features', () => { ); }); + it('should reject linking a new code-based provider with only an id and no credentials', async () => { + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + }, + }); + + // Sign up with username/password (no gpgames linked) + const user = new Parse.User(); + await user.signUp({ username: 'linkTestUser', password: 'password123' }); + const sessionToken = user.getSessionToken(); + + // Attempt to link gpgames with only { id } — no code or access_token + await expectAsync( + user.save({ authData: { gpgames: { id: 'victimUserId' } } }, { sessionToken }) + ).toBeRejectedWith( + jasmine.objectContaining({ message: jasmine.stringContaining('code is required') }) + ); + }); + it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { await reconfigureServer({ auth: { diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index 5346c05b55..301a0b4995 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -85,9 +85,14 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { /** * Validates auth data on first setup. On signup, `beforeFind` always runs first - * and validates credentials, so no additional credential check is needed here. + * and validates credentials. On update (linking a new provider), `beforeFind` + * may be skipped when authData has no credentials, so an explicit credential + * check is required here. */ validateSetUp(authData) { + if (!authData?.access_token) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); + } return { id: authData.id, } From 71ba75c550774e77fe366eb3e9a886596c9f8527 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:22:47 +0000 Subject: [PATCH 14/17] Revert "reject linking a new code-based provider with only an id and no credentials" This reverts commit d87503993545c48d197589c91fb2ba4370b989e3. --- spec/Adapters/Auth/BaseCodeAdapter.spec.js | 9 ++------- spec/AuthenticationAdaptersV2.spec.js | 23 ---------------------- src/Adapters/Auth/BaseCodeAuthAdapter.js | 7 +------ 3 files changed, 3 insertions(+), 36 deletions(-) diff --git a/spec/Adapters/Auth/BaseCodeAdapter.spec.js b/spec/Adapters/Auth/BaseCodeAdapter.spec.js index 231b8b0e03..a629ddad58 100644 --- a/spec/Adapters/Auth/BaseCodeAdapter.spec.js +++ b/spec/Adapters/Auth/BaseCodeAdapter.spec.js @@ -157,16 +157,11 @@ describe('BaseAuthCodeAdapter', function () { }); describe('validateSetUp', function () { - it('should return user id from authData with access_token', function () { - const authData = { id: 'validUserId', access_token: 'validAccessToken' }; + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; const result = adapter.validateSetUp(authData); expect(result).toEqual({ id: 'validUserId' }); }); - - it('should throw if access_token is missing', function () { - const authData = { id: 'validUserId' }; - expect(() => adapter.validateSetUp(authData)).toThrowError('TestAdapter code is required.'); - }); }); describe('afterFind', function () { diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 1cf2c77c2b..023ebd7c9b 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1553,29 +1553,6 @@ describe('Auth Adapter features', () => { ); }); - it('should reject linking a new code-based provider with only an id and no credentials', async () => { - await reconfigureServer({ - auth: { - gpgames: { - clientId: 'testClientId', - clientSecret: 'testClientSecret', - }, - }, - }); - - // Sign up with username/password (no gpgames linked) - const user = new Parse.User(); - await user.signUp({ username: 'linkTestUser', password: 'password123' }); - const sessionToken = user.getSessionToken(); - - // Attempt to link gpgames with only { id } — no code or access_token - await expectAsync( - user.save({ authData: { gpgames: { id: 'victimUserId' } } }, { sessionToken }) - ).toBeRejectedWith( - jasmine.objectContaining({ message: jasmine.stringContaining('code is required') }) - ); - }); - it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { await reconfigureServer({ auth: { diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index 301a0b4995..5346c05b55 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -85,14 +85,9 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { /** * Validates auth data on first setup. On signup, `beforeFind` always runs first - * and validates credentials. On update (linking a new provider), `beforeFind` - * may be skipped when authData has no credentials, so an explicit credential - * check is required here. + * and validates credentials, so no additional credential check is needed here. */ validateSetUp(authData) { - if (!authData?.access_token) { - throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); - } return { id: authData.id, } From 5201d7efab43df5911567c7f598a4bef3ba21eac Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Mon, 9 Feb 2026 23:22:51 +0000 Subject: [PATCH 15/17] Revert "reject change provider ID without credentials" This reverts commit 1d3ea7017f46393e02010869a71d66ed5acc46bc. --- spec/Adapters/Auth/BaseCodeAdapter.spec.js | 9 +--- spec/AuthenticationAdaptersV2.spec.js | 49 ---------------------- src/Adapters/Auth/BaseCodeAuthAdapter.js | 5 +-- 3 files changed, 3 insertions(+), 60 deletions(-) diff --git a/spec/Adapters/Auth/BaseCodeAdapter.spec.js b/spec/Adapters/Auth/BaseCodeAdapter.spec.js index a629ddad58..fef4b43306 100644 --- a/spec/Adapters/Auth/BaseCodeAdapter.spec.js +++ b/spec/Adapters/Auth/BaseCodeAdapter.spec.js @@ -173,15 +173,10 @@ describe('BaseAuthCodeAdapter', function () { }); describe('validateUpdate', function () { - it('should return user id from authData with access_token', function () { - const authData = { id: 'validUserId', access_token: 'validAccessToken' }; + it('should return user id from authData', function () { + const authData = { id: 'validUserId' }; const result = adapter.validateUpdate(authData); expect(result).toEqual({ id: 'validUserId' }); }); - - it('should throw if access_token is missing', function () { - const authData = { id: 'validUserId' }; - expect(() => adapter.validateUpdate(authData)).toThrowError('TestAdapter code is required.'); - }); }); }); diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index 023ebd7c9b..a341a4ed08 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1504,55 +1504,6 @@ describe('Auth Adapter features', () => { expect(finalAuthData.instagram.id).toBe(instagramUserId); }); - it('should reject updating a code-based provider with only an id and no credentials', async () => { - const mockUserId = 'gpgamesUser123'; - const mockAccessToken = 'mockAccessToken'; - - mockFetch([ - { - url: 'https://oauth2.googleapis.com/token', - method: 'POST', - response: { - ok: true, - json: () => Promise.resolve({ access_token: mockAccessToken }), - }, - }, - { - url: `https://www.googleapis.com/games/v1/players/${mockUserId}`, - method: 'GET', - response: { - ok: true, - json: () => Promise.resolve({ playerId: mockUserId }), - }, - }, - ]); - - await reconfigureServer({ - auth: { - gpgames: { - clientId: 'testClientId', - clientSecret: 'testClientSecret', - }, - }, - }); - - // Sign up and link gpgames with valid credentials - const user = new Parse.User(); - await user.save({ - authData: { - gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' }, - }, - }); - const sessionToken = user.getSessionToken(); - - // Attempt to change gpgames id without credentials (no code or access_token) - await expectAsync( - user.save({ authData: { gpgames: { id: 'differentUserId' } } }, { sessionToken }) - ).toBeRejectedWith( - jasmine.objectContaining({ message: jasmine.stringContaining('code is required') }) - ); - }); - it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { await reconfigureServer({ auth: { diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index 5346c05b55..139fcf22f4 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -107,12 +107,9 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { * authData has no credentials (e.g. echoed-back `{ id }` from `afterFind`). * Non-mutated echoed-back data is not validated at all. Mutated data without * credentials (e.g. a changed `id`) will reach this method without prior - * `beforeFind` validation, so an explicit credential check is required here. + * `beforeFind` validation. */ validateUpdate(authData) { - if (!authData?.access_token) { - throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`); - } return { id: authData.id, } From 22d8bf12f356f0aad0f48fa2cad6b0b7d99d9f95 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:59:00 +0000 Subject: [PATCH 16/17] fix fetch current authData to determine change --- src/Adapters/Auth/BaseCodeAuthAdapter.js | 19 +++++++++---------- src/Auth.js | 20 +++++++++++--------- src/RestWrite.js | 11 +++++++++-- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/Adapters/Auth/BaseCodeAuthAdapter.js b/src/Adapters/Auth/BaseCodeAuthAdapter.js index 139fcf22f4..9322d5bb23 100644 --- a/src/Adapters/Auth/BaseCodeAuthAdapter.js +++ b/src/Adapters/Auth/BaseCodeAuthAdapter.js @@ -73,9 +73,8 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } /** - * Validates auth data on login. On login, `beforeFind` always runs first and - * validates credentials (exchanges code for access_token, verifies identity), - * so no additional credential check is needed here. + * Validates auth data on login. `beforeFind` always runs first and validates + * credentials, so no additional credential check is needed here. */ validateLogin(authData) { return { @@ -84,8 +83,9 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } /** - * Validates auth data on first setup. On signup, `beforeFind` always runs first - * and validates credentials, so no additional credential check is needed here. + * Validates auth data on first setup or when linking a new provider. + * `beforeFind` always runs first and validates credentials, so no additional + * credential check is needed here. */ validateSetUp(authData) { return { @@ -103,11 +103,10 @@ export default class BaseAuthCodeAdapter extends AuthAdapter { } /** - * Validates auth data on update. On update, `beforeFind` is skipped when - * authData has no credentials (e.g. echoed-back `{ id }` from `afterFind`). - * Non-mutated echoed-back data is not validated at all. Mutated data without - * credentials (e.g. a changed `id`) will reach this method without prior - * `beforeFind` validation. + * Validates auth data on update. `beforeFind` runs first for any changed + * auth data and validates credentials, so no additional credential check + * is needed here. Unchanged (echoed-back) data skips both `beforeFind` + * and validation entirely. */ validateUpdate(authData) { return { diff --git a/src/Auth.js b/src/Auth.js index b7cffa8aac..331780961f 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -417,7 +417,7 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer }); }; -const findUsersWithAuthData = async (config, authData, beforeFind, isUpdate) => { +const findUsersWithAuthData = async (config, authData, beforeFind, currentUserAuthData) => { const providers = Object.keys(authData); const queries = await Promise.all( @@ -429,16 +429,18 @@ const findUsersWithAuthData = async (config, authData, beforeFind, isUpdate) => return null; } - const providerKeys = Object.keys(providerAuthData || {}); - const hasCredentials = providerKeys.some(key => key !== 'id'); + // Skip beforeFind only when incoming data is confirmed unchanged from stored data. + // This handles echoed-back authData from afterFind (e.g. client sends back { id: 'x' } + // alongside a provider unlink). On login/signup, currentUserAuthData is undefined so + // beforeFind always runs, preserving it as the security gate for missing credentials. + const storedProviderData = currentUserAuthData?.[provider]; + const incomingKeys = Object.keys(providerAuthData || {}); + const isUnchanged = storedProviderData && incomingKeys.length > 0 && + !incomingKeys.some(key => !isDeepStrictEqual(providerAuthData[key], storedProviderData[key])); - // On update, skip beforeFind for echoed-back authData (no credentials to process). - // On login/signup, always call beforeFind so it can reject missing credentials. const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter; - if (beforeFind && typeof adapter?.beforeFind === 'function') { - if (hasCredentials || !isUpdate) { - await adapter.beforeFind(providerAuthData); - } + if (beforeFind && typeof adapter?.beforeFind === 'function' && !isUnchanged) { + await adapter.beforeFind(providerAuthData); } if (!providerAuthData?.id) { diff --git a/src/RestWrite.js b/src/RestWrite.js index b9a9ae1674..5eee53a937 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -541,8 +541,15 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () { }; RestWrite.prototype.handleAuthData = async function (authData) { - const isUpdate = !!this.query; - const r = await Auth.findUsersWithAuthData(this.config, authData, true, isUpdate); + let currentUserAuthData; + if (this.query?.objectId) { + const [currentUser] = await this.config.database.find( + '_User', + { objectId: this.query.objectId } + ); + currentUserAuthData = currentUser?.authData; + } + const r = await Auth.findUsersWithAuthData(this.config, authData, true, currentUserAuthData); const results = this.filteredObjectsByACL(r); const userId = this.getUserId(); From c74b44e5f743897e5c59d7fb30c7848fb3158431 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:47:24 +0000 Subject: [PATCH 17/17] add neg tests --- spec/AuthenticationAdaptersV2.spec.js | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/spec/AuthenticationAdaptersV2.spec.js b/spec/AuthenticationAdaptersV2.spec.js index a341a4ed08..d8c646382c 100644 --- a/spec/AuthenticationAdaptersV2.spec.js +++ b/spec/AuthenticationAdaptersV2.spec.js @@ -1504,6 +1504,78 @@ describe('Auth Adapter features', () => { expect(finalAuthData.instagram.id).toBe(instagramUserId); }); + it('should reject changing an existing code-based provider id without credentials', async () => { + const mockUserId = 'gpgamesUser123'; + const mockAccessToken = 'mockAccessToken'; + + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { + ok: true, + json: () => Promise.resolve({ access_token: mockAccessToken }), + }, + }, + { + url: `https://www.googleapis.com/games/v1/players/${mockUserId}`, + method: 'GET', + response: { + ok: true, + json: () => Promise.resolve({ playerId: mockUserId }), + }, + }, + ]); + + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + }, + }); + + // Sign up and link gpgames with valid credentials + const user = new Parse.User(); + await user.save({ + authData: { + gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' }, + }, + }); + const sessionToken = user.getSessionToken(); + + // Attempt to change gpgames id without credentials (no code or access_token) + await expectAsync( + user.save({ authData: { gpgames: { id: 'differentUserId' } } }, { sessionToken }) + ).toBeRejectedWith( + jasmine.objectContaining({ message: jasmine.stringContaining('code is required') }) + ); + }); + + it('should reject linking a new code-based provider with only an id and no credentials', async () => { + await reconfigureServer({ + auth: { + gpgames: { + clientId: 'testClientId', + clientSecret: 'testClientSecret', + }, + }, + }); + + // Sign up with username/password (no gpgames linked) + const user = new Parse.User(); + await user.signUp({ username: 'linkTestUser', password: 'password123' }); + const sessionToken = user.getSessionToken(); + + // Attempt to link gpgames with only { id } — no code or access_token + await expectAsync( + user.save({ authData: { gpgames: { id: 'victimUserId' } } }, { sessionToken }) + ).toBeRejectedWith( + jasmine.objectContaining({ message: jasmine.stringContaining('code is required') }) + ); + }); + it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => { await reconfigureServer({ auth: {