Skip to content

Commit b6b6327

Browse files
authored
fix: Unlinking auth provider triggers auth data validation (#10045)
1 parent e64b52f commit b6b6327

File tree

5 files changed

+296
-13
lines changed

5 files changed

+296
-13
lines changed

spec/AuthenticationAdaptersV2.spec.js

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,244 @@ describe('Auth Adapter features', () => {
13381338
expect(user.get('authData')).toEqual({ adapterB: { id: 'test' } });
13391339
});
13401340

1341+
it('should unlink a code-based auth provider without triggering adapter validation', async () => {
1342+
const mockUserId = 'gpgamesUser123';
1343+
const mockAccessToken = 'mockAccessToken';
1344+
1345+
const otherAdapter = {
1346+
validateAppId: () => Promise.resolve(),
1347+
validateAuthData: () => Promise.resolve(),
1348+
};
1349+
1350+
mockFetch([
1351+
{
1352+
url: 'https://oauth2.googleapis.com/token',
1353+
method: 'POST',
1354+
response: {
1355+
ok: true,
1356+
json: () => Promise.resolve({ access_token: mockAccessToken }),
1357+
},
1358+
},
1359+
{
1360+
url: `https://www.googleapis.com/games/v1/players/${mockUserId}`,
1361+
method: 'GET',
1362+
response: {
1363+
ok: true,
1364+
json: () => Promise.resolve({ playerId: mockUserId }),
1365+
},
1366+
},
1367+
]);
1368+
1369+
await reconfigureServer({
1370+
auth: {
1371+
gpgames: {
1372+
clientId: 'testClientId',
1373+
clientSecret: 'testClientSecret',
1374+
},
1375+
otherAdapter,
1376+
},
1377+
});
1378+
1379+
// Sign up with username/password, then link providers
1380+
const user = new Parse.User();
1381+
await user.signUp({ username: 'gpgamesTestUser', password: 'password123' });
1382+
1383+
// Link gpgames code-based provider
1384+
await user.save({
1385+
authData: {
1386+
gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' },
1387+
},
1388+
});
1389+
1390+
// Link a second provider
1391+
await user.save({ authData: { otherAdapter: { id: 'other1' } } });
1392+
1393+
// Reset fetch spy to track calls during unlink
1394+
global.fetch.calls.reset();
1395+
1396+
// Unlink gpgames by setting authData to null; should not call beforeFind / external APIs
1397+
const sessionToken = user.getSessionToken();
1398+
await user.save({ authData: { gpgames: null } }, { sessionToken });
1399+
1400+
// No external HTTP calls should have been made during unlink
1401+
expect(global.fetch.calls.count()).toBe(0);
1402+
1403+
// Verify gpgames was removed while the other provider remains
1404+
await user.fetch({ useMasterKey: true });
1405+
const authData = user.get('authData');
1406+
expect(authData).toBeDefined();
1407+
expect(authData.gpgames).toBeUndefined();
1408+
expect(authData.otherAdapter).toEqual({ id: 'other1' });
1409+
});
1410+
1411+
it('should unlink one code-based provider while echoing back another unchanged', async () => {
1412+
const gpgamesUserId = 'gpgamesUser1';
1413+
const instagramUserId = 'igUser1';
1414+
1415+
// Mock gpgames API for initial login
1416+
mockFetch([
1417+
{
1418+
url: 'https://oauth2.googleapis.com/token',
1419+
method: 'POST',
1420+
response: {
1421+
ok: true,
1422+
json: () => Promise.resolve({ access_token: 'gpgamesToken' }),
1423+
},
1424+
},
1425+
{
1426+
url: `https://www.googleapis.com/games/v1/players/${gpgamesUserId}`,
1427+
method: 'GET',
1428+
response: {
1429+
ok: true,
1430+
json: () => Promise.resolve({ playerId: gpgamesUserId }),
1431+
},
1432+
},
1433+
]);
1434+
1435+
await reconfigureServer({
1436+
auth: {
1437+
gpgames: {
1438+
clientId: 'testClientId',
1439+
clientSecret: 'testClientSecret',
1440+
},
1441+
instagram: {
1442+
clientId: 'testClientId',
1443+
clientSecret: 'testClientSecret',
1444+
redirectUri: 'https://example.com/callback',
1445+
},
1446+
},
1447+
});
1448+
1449+
// Login with gpgames
1450+
const user = await Parse.User.logInWith('gpgames', {
1451+
authData: { id: gpgamesUserId, code: 'gpCode1', redirect_uri: 'https://example.com/callback' },
1452+
});
1453+
const sessionToken = user.getSessionToken();
1454+
1455+
// Mock instagram API for linking
1456+
mockFetch([
1457+
{
1458+
url: 'https://api.instagram.com/oauth/access_token',
1459+
method: 'POST',
1460+
response: {
1461+
ok: true,
1462+
json: () => Promise.resolve({ access_token: 'igToken' }),
1463+
},
1464+
},
1465+
{
1466+
url: `https://graph.instagram.com/me?fields=id&access_token=igToken`,
1467+
method: 'GET',
1468+
response: {
1469+
ok: true,
1470+
json: () => Promise.resolve({ id: instagramUserId }),
1471+
},
1472+
},
1473+
]);
1474+
1475+
// Link instagram as second provider
1476+
await user.save(
1477+
{ authData: { instagram: { id: instagramUserId, code: 'igCode1' } } },
1478+
{ sessionToken }
1479+
);
1480+
1481+
// Fetch to get current authData (afterFind strips credentials, leaving only { id })
1482+
await user.fetch({ sessionToken });
1483+
const currentAuthData = user.get('authData');
1484+
expect(currentAuthData.gpgames).toBeDefined();
1485+
expect(currentAuthData.instagram).toBeDefined();
1486+
1487+
// Reset fetch spy
1488+
global.fetch.calls.reset();
1489+
1490+
// Unlink gpgames while echoing back instagram unchanged — the common client pattern:
1491+
// fetch current state, spread it, set the one to unlink to null
1492+
user.set('authData', { ...currentAuthData, gpgames: null });
1493+
await user.save(null, { sessionToken });
1494+
1495+
// No external HTTP calls during unlink (no code exchange for unchanged instagram)
1496+
expect(global.fetch.calls.count()).toBe(0);
1497+
1498+
// Verify gpgames removed, instagram preserved
1499+
await user.fetch({ useMasterKey: true });
1500+
const finalAuthData = user.get('authData');
1501+
expect(finalAuthData).toBeDefined();
1502+
expect(finalAuthData.gpgames).toBeUndefined();
1503+
expect(finalAuthData.instagram).toBeDefined();
1504+
expect(finalAuthData.instagram.id).toBe(instagramUserId);
1505+
});
1506+
1507+
it('should reject changing an existing code-based provider id without credentials', async () => {
1508+
const mockUserId = 'gpgamesUser123';
1509+
const mockAccessToken = 'mockAccessToken';
1510+
1511+
mockFetch([
1512+
{
1513+
url: 'https://oauth2.googleapis.com/token',
1514+
method: 'POST',
1515+
response: {
1516+
ok: true,
1517+
json: () => Promise.resolve({ access_token: mockAccessToken }),
1518+
},
1519+
},
1520+
{
1521+
url: `https://www.googleapis.com/games/v1/players/${mockUserId}`,
1522+
method: 'GET',
1523+
response: {
1524+
ok: true,
1525+
json: () => Promise.resolve({ playerId: mockUserId }),
1526+
},
1527+
},
1528+
]);
1529+
1530+
await reconfigureServer({
1531+
auth: {
1532+
gpgames: {
1533+
clientId: 'testClientId',
1534+
clientSecret: 'testClientSecret',
1535+
},
1536+
},
1537+
});
1538+
1539+
// Sign up and link gpgames with valid credentials
1540+
const user = new Parse.User();
1541+
await user.save({
1542+
authData: {
1543+
gpgames: { id: mockUserId, code: 'authCode123', redirect_uri: 'https://example.com/callback' },
1544+
},
1545+
});
1546+
const sessionToken = user.getSessionToken();
1547+
1548+
// Attempt to change gpgames id without credentials (no code or access_token)
1549+
await expectAsync(
1550+
user.save({ authData: { gpgames: { id: 'differentUserId' } } }, { sessionToken })
1551+
).toBeRejectedWith(
1552+
jasmine.objectContaining({ message: jasmine.stringContaining('code is required') })
1553+
);
1554+
});
1555+
1556+
it('should reject linking a new code-based provider with only an id and no credentials', async () => {
1557+
await reconfigureServer({
1558+
auth: {
1559+
gpgames: {
1560+
clientId: 'testClientId',
1561+
clientSecret: 'testClientSecret',
1562+
},
1563+
},
1564+
});
1565+
1566+
// Sign up with username/password (no gpgames linked)
1567+
const user = new Parse.User();
1568+
await user.signUp({ username: 'linkTestUser', password: 'password123' });
1569+
const sessionToken = user.getSessionToken();
1570+
1571+
// Attempt to link gpgames with only { id } — no code or access_token
1572+
await expectAsync(
1573+
user.save({ authData: { gpgames: { id: 'victimUserId' } } }, { sessionToken })
1574+
).toBeRejectedWith(
1575+
jasmine.objectContaining({ message: jasmine.stringContaining('code is required') })
1576+
);
1577+
});
1578+
13411579
it('should handle multiple providers: add one while another remains unchanged (code-based)', async () => {
13421580
await reconfigureServer({
13431581
auth: {

src/Adapters/Auth/BaseCodeAuthAdapter.js

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,32 +72,47 @@ export default class BaseAuthCodeAdapter extends AuthAdapter {
7272
throw new Error('getAccessTokenFromCode is not implemented');
7373
}
7474

75+
/**
76+
* Validates auth data on login. In the standard auth flows (login, signup,
77+
* update), `beforeFind` runs first and validates credentials, so no
78+
* additional credential check is needed here.
79+
*/
7580
validateLogin(authData) {
76-
// User validation is already done in beforeFind
7781
return {
7882
id: authData.id,
7983
}
8084
}
8185

86+
/**
87+
* Validates auth data on first setup or when linking a new provider.
88+
* In the standard auth flows, `beforeFind` runs first and validates
89+
* credentials, so no additional credential check is needed here.
90+
*/
8291
validateSetUp(authData) {
83-
// User validation is already done in beforeFind
8492
return {
8593
id: authData.id,
8694
}
8795
}
8896

97+
/**
98+
* Returns the auth data to expose to the client after a query.
99+
*/
89100
afterFind(authData) {
90101
return {
91102
id: authData.id,
92103
}
93104
}
94105

106+
/**
107+
* Validates auth data on update. In the standard auth flows, `beforeFind`
108+
* runs first for any changed auth data and validates credentials, so no
109+
* additional credential check is needed here. Unchanged (echoed-back) data
110+
* skips both `beforeFind` and validation entirely.
111+
*/
95112
validateUpdate(authData) {
96-
// User validation is already done in beforeFind
97113
return {
98114
id: authData.id,
99115
}
100-
101116
}
102117

103118
parseResponseData(data) {

src/Adapters/Storage/Postgres/PostgresStorageAdapter.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,20 +1607,28 @@ export class PostgresStorageAdapter implements StorageAdapter {
16071607
const generate = (jsonb: string, key: string, value: any) => {
16081608
return `json_object_set_key(COALESCE(${jsonb}, '{}'::jsonb), ${key}, ${value})::jsonb`;
16091609
};
1610+
const generateRemove = (jsonb: string, key: string) => {
1611+
return `(COALESCE(${jsonb}, '{}'::jsonb) - ${key})`;
1612+
};
16101613
const lastKey = `$${index}:name`;
16111614
const fieldNameIndex = index;
16121615
index += 1;
16131616
values.push(fieldName);
16141617
const update = Object.keys(fieldValue).reduce((lastKey: string, key: string) => {
1618+
let value = fieldValue[key];
1619+
if (value && value.__op === 'Delete') {
1620+
value = null;
1621+
}
1622+
if (value === null) {
1623+
const str = generateRemove(lastKey, `$${index}::text`);
1624+
values.push(key);
1625+
index += 1;
1626+
return str;
1627+
}
16151628
const str = generate(lastKey, `$${index}::text`, `$${index + 1}::jsonb`);
16161629
index += 2;
1617-
let value = fieldValue[key];
16181630
if (value) {
1619-
if (value.__op === 'Delete') {
1620-
value = null;
1621-
} else {
1622-
value = JSON.stringify(value);
1623-
}
1631+
value = JSON.stringify(value);
16241632
}
16251633
values.push(key, value);
16261634
return str;

src/Auth.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,15 +417,29 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer
417417
});
418418
};
419419

420-
const findUsersWithAuthData = async (config, authData, beforeFind) => {
420+
const findUsersWithAuthData = async (config, authData, beforeFind, currentUserAuthData) => {
421421
const providers = Object.keys(authData);
422422

423423
const queries = await Promise.all(
424424
providers.map(async provider => {
425425
const providerAuthData = authData[provider];
426426

427+
// Skip providers being unlinked (null value)
428+
if (providerAuthData === null) {
429+
return null;
430+
}
431+
432+
// Skip beforeFind only when incoming data is confirmed unchanged from stored data.
433+
// This handles echoed-back authData from afterFind (e.g. client sends back { id: 'x' }
434+
// alongside a provider unlink). On login/signup, currentUserAuthData is undefined so
435+
// beforeFind always runs, preserving it as the security gate for missing credentials.
436+
const storedProviderData = currentUserAuthData?.[provider];
437+
const incomingKeys = Object.keys(providerAuthData || {});
438+
const isUnchanged = storedProviderData && incomingKeys.length > 0 &&
439+
!incomingKeys.some(key => !isDeepStrictEqual(providerAuthData[key], storedProviderData[key]));
440+
427441
const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter;
428-
if (beforeFind && typeof adapter?.beforeFind === 'function') {
442+
if (beforeFind && typeof adapter?.beforeFind === 'function' && !isUnchanged) {
429443
await adapter.beforeFind(providerAuthData);
430444
}
431445

src/RestWrite.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -541,7 +541,15 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () {
541541
};
542542

543543
RestWrite.prototype.handleAuthData = async function (authData) {
544-
const r = await Auth.findUsersWithAuthData(this.config, authData, true);
544+
let currentUserAuthData;
545+
if (this.query?.objectId) {
546+
const [currentUser] = await this.config.database.find(
547+
'_User',
548+
{ objectId: this.query.objectId }
549+
);
550+
currentUserAuthData = currentUser?.authData;
551+
}
552+
const r = await Auth.findUsersWithAuthData(this.config, authData, true, currentUserAuthData);
545553
const results = this.filteredObjectsByACL(r);
546554

547555
const userId = this.getUserId();

0 commit comments

Comments
 (0)