Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spec/AuthenticationAdaptersV2.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
260 changes: 260 additions & 0 deletions spec/Users.authdata.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
77 changes: 74 additions & 3 deletions src/Auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -423,10 +423,15 @@
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);

Check failure on line 433 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 10 spaces but found 9
}

Check failure on line 434 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected indentation of 8 spaces but found 7
}

if (!providerAuthData?.id) {
Expand Down Expand Up @@ -601,6 +606,70 @@
return acc;
};

const subsetEqual = (prev, next) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: diff and equality system should not be coded from scratch and if not done correctly could be an attack vector, popular libs are native implementation should be used

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback on diff/equality — agreed. I overreached trying to fix too much at once. I’ve reduced this PR to minimal changes only:

Before findUsersWithAuthData, I filter out providers where authData[provider] is null (or undefined), so lookup/validation runs only on non-null providers.

Unlink via authData[provider] = null is applied without invoking the adapter.

If we revisit partial update semantics later, I’ll rely on native or well-vetted utilities and propose that in a separate PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ohh thanks @SNtGog diffs looks much better 🚀

if (prev === next) return true;

Check failure on line 610 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition
if (prev == null || next == null) return false;

Check failure on line 611 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition

const tp = typeof prev;
const tn = typeof next;
if (tn !== 'object' || tp !== 'object') return prev === next;

Check failure on line 615 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition

// arrays: require element-wise equality for the provided portion
if (Array.isArray(next)) {
if (!Array.isArray(prev)) return false;

Check failure on line 619 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition
if (next.length !== prev.length) return false;

Check failure on line 620 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition
for (let i = 0; i < next.length; i++) {
if (!subsetEqual(prev[i], next[i])) return false;

Check failure on line 622 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition
}
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

Check failure on line 630 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition
const pv = prev[k];
if (!subsetEqual(pv, nv)) return false;

Check failure on line 632 in src/Auth.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition
}
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,
Expand All @@ -614,4 +683,6 @@
hasMutatedAuthData,
checkIfUserHasProvidedConfiguredProvidersForLogin,
handleAuthDataValidation,
subsetEqual,
diffAuthData
};
Loading
Loading