fix: Authentication provider credentials are usable across Parse Server apps; fixes security vulnerability [GHSA-837q-jhwx-cmpv](https://github.com/parse-community/parse-server/security/advisories/GHSA-837q-jhwx-cmpv) (#9667)

This commit is contained in:
Manuel
2025-03-21 10:49:09 +01:00
committed by GitHub
parent c56b2c49b2
commit 5ef0440c8e
59 changed files with 5987 additions and 1680 deletions

View File

@@ -0,0 +1,182 @@
const BaseAuthCodeAdapter = require('../../../lib/Adapters/Auth/BaseCodeAuthAdapter').default;
describe('BaseAuthCodeAdapter', function () {
let adapter;
const adapterName = 'TestAdapter';
const validOptions = {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
};
class TestAuthCodeAdapter extends BaseAuthCodeAdapter {
async getUserFromAccessToken(accessToken) {
if (accessToken === 'validAccessToken') {
return { id: 'validUserId' };
}
throw new Error('Invalid access token');
}
async getAccessTokenFromCode(authData) {
if (authData.code === 'validCode') {
return 'validAccessToken';
}
throw new Error('Invalid code');
}
}
beforeEach(function () {
adapter = new TestAuthCodeAdapter(adapterName);
});
describe('validateOptions', function () {
it('should throw error if options are missing', function () {
expect(() => adapter.validateOptions(null)).toThrowError(`${adapterName} options are required.`);
});
it('should throw error if clientId is missing in secure mode', function () {
expect(() =>
adapter.validateOptions({ clientSecret: 'validClientSecret' })
).toThrowError(`${adapterName} clientId is required.`);
});
it('should throw error if clientSecret is missing in secure mode', function () {
expect(() =>
adapter.validateOptions({ clientId: 'validClientId' })
).toThrowError(`${adapterName} clientSecret is required.`);
});
it('should not throw error for valid options', function () {
expect(() => adapter.validateOptions(validOptions)).not.toThrow();
expect(adapter.clientId).toBe('validClientId');
expect(adapter.clientSecret).toBe('validClientSecret');
expect(adapter.enableInsecureAuth).toBeUndefined();
});
it('should allow insecure mode without clientId or clientSecret', function () {
const options = { enableInsecureAuth: true };
expect(() => adapter.validateOptions(options)).not.toThrow();
expect(adapter.enableInsecureAuth).toBe(true);
});
});
describe('beforeFind', function () {
it('should throw error if code is missing in secure mode', async function () {
adapter.validateOptions(validOptions);
const authData = { access_token: 'validAccessToken' };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError(
`${adapterName} code is required.`
);
});
it('should throw error if access token is missing in insecure mode', async function () {
adapter.validateOptions({ enableInsecureAuth: true });
const authData = {};
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError(
`${adapterName} auth is invalid for this user.`
);
});
it('should throw error if user ID does not match in insecure mode', async function () {
adapter.validateOptions({ enableInsecureAuth: true });
const authData = { id: 'invalidUserId', access_token: 'validAccessToken' };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWithError(
`${adapterName} auth is invalid for this user.`
);
});
it('should process valid secure payload and update authData', async function () {
adapter.validateOptions(validOptions);
const authData = { code: 'validCode' };
await adapter.beforeFind(authData);
expect(authData.access_token).toBe('validAccessToken');
expect(authData.id).toBe('validUserId');
expect(authData.code).toBeUndefined();
});
it('should process valid insecure payload', async function () {
adapter.validateOptions({ enableInsecureAuth: true });
const authData = { id: 'validUserId', access_token: 'validAccessToken' };
await expectAsync(adapter.beforeFind(authData)).toBeResolved();
});
});
describe('getUserFromAccessToken', function () {
it('should throw error if not implemented in base class', async function () {
const baseAdapter = new BaseAuthCodeAdapter(adapterName);
await expectAsync(baseAdapter.getUserFromAccessToken('test')).toBeRejectedWithError(
'getUserFromAccessToken is not implemented'
);
});
it('should return valid user for valid access token', async function () {
const user = await adapter.getUserFromAccessToken('validAccessToken', {});
expect(user).toEqual({ id: 'validUserId' });
});
it('should throw error for invalid access token', async function () {
await expectAsync(adapter.getUserFromAccessToken('invalidAccessToken', {})).toBeRejectedWithError(
'Invalid access token'
);
});
});
describe('getAccessTokenFromCode', function () {
it('should throw error if not implemented in base class', async function () {
const baseAdapter = new BaseAuthCodeAdapter(adapterName);
await expectAsync(baseAdapter.getAccessTokenFromCode({ code: 'test' })).toBeRejectedWithError(
'getAccessTokenFromCode is not implemented'
);
});
it('should return valid access token for valid code', async function () {
const accessToken = await adapter.getAccessTokenFromCode({ code: 'validCode' });
expect(accessToken).toBe('validAccessToken');
});
it('should throw error for invalid code', async function () {
await expectAsync(adapter.getAccessTokenFromCode({ code: 'invalidCode' })).toBeRejectedWithError(
'Invalid code'
);
});
});
describe('validateLogin', function () {
it('should return user id from authData', function () {
const authData = { id: 'validUserId' };
const result = adapter.validateLogin(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
describe('validateSetUp', function () {
it('should return user id from authData', function () {
const authData = { id: 'validUserId' };
const result = adapter.validateSetUp(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
describe('afterFind', function () {
it('should return user id from authData', function () {
const authData = { id: 'validUserId' };
const result = adapter.afterFind(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
describe('validateUpdate', function () {
it('should return user id from authData', function () {
const authData = { id: 'validUserId' };
const result = adapter.validateUpdate(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
});

View File

@@ -0,0 +1,220 @@
const GameCenterAuth = require('../../../lib/Adapters/Auth/gcenter').default;
const { pki } = require('node-forge');
const fs = require('fs');
const path = require('path');
describe('GameCenterAuth Adapter', function () {
let adapter;
beforeEach(function () {
adapter = new GameCenterAuth.constructor();
const gcProd4 = fs.readFileSync(path.resolve(__dirname, '../../support/cert/gc-prod-4.cer'));
const digicertPem = fs.readFileSync(path.resolve(__dirname, '../../support/cert/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem')).toString();
mockFetch([
{
url: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
method: 'GET',
response: {
ok: true,
headers: new Map(),
arrayBuffer: () => Promise.resolve(
gcProd4.buffer.slice(gcProd4.byteOffset, gcProd4.byteOffset + gcProd4.length)
),
},
},
{
url: 'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
method: 'GET',
response: {
ok: true,
headers: new Map([['content-type', 'application/x-pem-file'], ['content-length', digicertPem.length.toString()]]),
text: () => Promise.resolve(digicertPem),
},
}
]);
});
describe('Test config failing due to missing params or wrong types', function () {
it('should throw error for invalid options', async function () {
const invalidOptions = [
null,
undefined,
{},
{ bundleId: '' },
{ enableInsecureAuth: false }, // Missing bundleId in secure mode
];
for (const options of invalidOptions) {
expect(() => adapter.validateOptions(options)).withContext(JSON.stringify(options)).toThrow()
}
});
it('should validate options successfully with valid parameters', function () {
const validOptions = { bundleId: 'com.valid.app', enableInsecureAuth: false };
expect(() => adapter.validateOptions(validOptions)).not.toThrow();
});
});
describe('Test payload failing due to missing params or wrong types', function () {
it('should throw error for missing authData fields', async function () {
await expectAsync(adapter.validateAuthData({})).toBeRejectedWithError(
'AuthData id is missing.'
);
});
});
describe('Test payload fails due to incorrect appId / certificate', function () {
it('should throw error for invalid publicKeyUrl', async function () {
const invalidPublicKeyUrl = 'https://malicious.url.com/key.cer';
spyOn(adapter, 'fetchCertificate').and.throwError(
new Error('Invalid publicKeyUrl')
);
await expectAsync(
adapter.getAppleCertificate(invalidPublicKeyUrl)
).toBeRejectedWithError('Invalid publicKeyUrl: https://malicious.url.com/key.cer');
});
it('should throw error for invalid signature verification', async function () {
const fakePublicKey = 'invalid-key';
const fakeAuthData = {
id: '1234567',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1460981421303,
salt: 'saltST==',
signature: 'invalidSignature',
};
spyOn(adapter, 'getAppleCertificate').and.returnValue(Promise.resolve(fakePublicKey));
spyOn(adapter, 'verifySignature').and.throwError('Invalid signature.');
await expectAsync(adapter.validateAuthData(fakeAuthData)).toBeRejectedWithError(
'Invalid signature.'
);
});
});
describe('Test payload passing', function () {
it('should successfully process valid payload and save auth data', async function () {
const validAuthData = {
id: '1234567',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1460981421303,
salt: 'saltST==',
signature: 'validSignature',
bundleId: 'com.valid.app',
};
spyOn(adapter, 'getAppleCertificate').and.returnValue(Promise.resolve('validKey'));
spyOn(adapter, 'verifySignature').and.returnValue(true);
await expectAsync(adapter.validateAuthData(validAuthData)).toBeResolved();
});
});
describe('Certificate and Signature Validation', function () {
it('should fetch and validate Apple certificate', async function () {
const certUrl = 'https://static.gc.apple.com/public-key/gc-prod-4.cer';
const mockCertificate = 'mockCertificate';
spyOn(adapter, 'fetchCertificate').and.returnValue(
Promise.resolve({ certificate: mockCertificate, headers: new Map() })
);
spyOn(pki, 'certificateFromPem').and.returnValue({});
adapter.cache[certUrl] = mockCertificate;
const cert = await adapter.getAppleCertificate(certUrl);
expect(cert).toBe(mockCertificate);
});
it('should verify signature successfully', async function () {
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1565257031287,
signature:
'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==',
salt: 'DzqqrQ==',
};
adapter.bundleId = 'cloud.xtralife.gamecenterauth';
adapter.enableInsecureAuth = false;
spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue();
const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl);
expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow();
});
it('should not use bundle id from authData payload in secure mode', async function () {
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1565257031287,
signature:
'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==',
salt: 'DzqqrQ==',
bundleId: 'com.example.insecure.app',
};
adapter.bundleId = 'cloud.xtralife.gamecenterauth';
adapter.enableInsecureAuth = false;
spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue();
const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl);
expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow();
});
it('should not use bundle id from authData payload in insecure mode', async function () {
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1565257031287,
signature:
'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==',
salt: 'DzqqrQ==',
bundleId: 'com.example.insecure.app',
};
adapter.bundleId = 'cloud.xtralife.gamecenterauth';
adapter.enableInsecureAuth = true;
spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue();
const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl);
expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow();
});
it('can use bundle id from authData payload in insecure mode', async function () {
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1565257031287,
signature:
'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==',
salt: 'DzqqrQ==',
bundleId: 'cloud.xtralife.gamecenterauth',
};
adapter.enableInsecureAuth = true;
spyOn(adapter, 'verifyPublicKeyIssuer').and.returnValue();
const publicKey = await adapter.getAppleCertificate(authData.publicKeyUrl);
expect(() => adapter.verifySignature(publicKey, authData)).not.toThrow();
});
});
});

View File

@@ -0,0 +1,285 @@
const GitHubAdapter = require('../../../lib/Adapters/Auth/github').default;
describe('GitHubAdapter', function () {
let adapter;
const validOptions = {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
};
beforeEach(function () {
adapter = new GitHubAdapter.constructor();
adapter.validateOptions(validOptions);
});
describe('getAccessTokenFromCode', function () {
it('should fetch an access token successfully', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken',
}),
},
},
]);
const code = 'validCode';
const token = await adapter.getAccessTokenFromCode(code);
expect(token).toBe('mockAccessToken');
});
it('should throw an error if the response is not ok', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
const code = 'invalidCode';
await expectAsync(adapter.getAccessTokenFromCode(code)).toBeRejectedWithError(
'Failed to exchange code for token: Bad Request'
);
});
it('should throw an error if the response contains an error', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
error: 'invalid_grant',
error_description: 'Code is invalid',
}),
},
},
]);
const code = 'invalidCode';
await expectAsync(adapter.getAccessTokenFromCode(code)).toBeRejectedWithError('Code is invalid');
});
});
describe('getUserFromAccessToken', function () {
it('should fetch user data successfully', async function () {
mockFetch([
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
login: 'mockUserLogin',
}),
},
},
]);
const accessToken = 'validAccessToken';
const user = await adapter.getUserFromAccessToken(accessToken);
expect(user).toEqual({ id: 'mockUserId', login: 'mockUserLogin' });
});
it('should throw an error if the response is not ok', async function () {
mockFetch([
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const accessToken = 'invalidAccessToken';
await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError(
'Failed to fetch GitHub user: Unauthorized'
);
});
it('should throw an error if user data is invalid', async function () {
mockFetch([
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({}),
},
},
]);
const accessToken = 'validAccessToken';
await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError(
'Invalid GitHub user data received.'
);
});
});
describe('GitHubAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
github: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
},
},
});
});
it('should log in user using GitHub adapter successfully', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
login: 'mockUserLogin',
}),
},
},
]);
const authData = { code: 'validCode' };
const user = await Parse.User.logInWith('github', { authData });
expect(user.id).toBeDefined();
});
it('should handle error when GitHub returns invalid code', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = { code: 'invalidCode' };
await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError(
'Failed to exchange code for token: Invalid code'
);
});
it('should handle error when GitHub returns invalid user data', async function () {
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = { code: 'validCode' };
await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError(
'Failed to fetch GitHub user: Unauthorized'
);
});
it('e2e secure does not support insecure payload', async function () {
mockFetch();
const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' };
await expectAsync(Parse.User.logInWith('github', { authData })).toBeRejectedWithError(
'GitHub code is required.'
);
});
it('e2e insecure does support secure payload', async function () {
await reconfigureServer({
auth: {
github: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: true,
},
},
});
mockFetch([
{
url: 'https://github.com/login/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.github.com/user',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
login: 'mockUserLogin',
}),
},
},
]);
const authData = { code: 'validCode' };
const user = await Parse.User.logInWith('github', { authData });
expect(user.id).toBeDefined();
});
});
});

View File

@@ -0,0 +1,356 @@
const GooglePlayGamesServicesAdapter = require('../../../lib/Adapters/Auth/gpgames').default;
describe('GooglePlayGamesServicesAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new GooglePlayGamesServicesAdapter.constructor();
adapter.clientId = 'validClientId';
adapter.clientSecret = 'validClientSecret';
});
describe('getAccessTokenFromCode', function () {
it('should fetch an access token successfully', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken',
}),
},
},
]);
const code = 'validCode';
const authData = { redirectUri: 'http://example.com' };
const token = await adapter.getAccessTokenFromCode(code, authData);
expect(token).toBe('mockAccessToken');
});
it('should throw an error if the response is not ok', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
const code = 'invalidCode';
const authData = { redirectUri: 'http://example.com' };
await expectAsync(adapter.getAccessTokenFromCode(code, authData)).toBeRejectedWithError(
'Failed to exchange code for token: Bad Request'
);
});
it('should throw an error if the response contains an error', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
error: 'invalid_grant',
error_description: 'Code is invalid',
}),
},
},
]);
const code = 'invalidCode';
const authData = { redirectUri: 'http://example.com' };
await expectAsync(adapter.getAccessTokenFromCode(code, authData)).toBeRejectedWithError(
'Code is invalid'
);
});
});
describe('getUserFromAccessToken', function () {
it('should fetch user data successfully', async function () {
mockFetch([
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
playerId: 'mockUserId',
}),
},
},
]);
const accessToken = 'validAccessToken';
const authData = { id: 'mockUserId' };
const user = await adapter.getUserFromAccessToken(accessToken, authData);
expect(user).toEqual({ id: 'mockUserId' });
});
it('should throw an error if the response is not ok', async function () {
mockFetch([
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const accessToken = 'invalidAccessToken';
const authData = { id: 'mockUserId' };
await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError(
'Failed to fetch Google Play Games Services user: Unauthorized'
);
});
it('should throw an error if user data is invalid', async function () {
mockFetch([
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({}),
},
},
]);
const accessToken = 'validAccessToken';
const authData = { id: 'mockUserId' };
await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError(
'Invalid Google Play Games Services user data received.'
);
});
it('should throw an error if playerId does not match the provided user ID', async function () {
mockFetch([
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
playerId: 'anotherUserId',
}),
},
},
]);
const accessToken = 'validAccessToken';
const authData = { id: 'mockUserId' };
await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError(
'Invalid Google Play Games Services user data received.'
);
});
});
describe('GooglePlayGamesServicesAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
gpgames: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
},
},
});
});
it('should log in user successfully with valid code', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
playerId: 'mockUserId',
}),
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
redirectUri: 'http://example.com',
};
const user = await Parse.User.logInWith('gpgames', { authData });
expect(user.id).toBeDefined();
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.googleapis.com/games/v1/players/mockUserId',
jasmine.any(Object)
);
});
it('should handle error when the token exchange fails', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = {
code: 'invalidCode',
redirectUri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError(
'Failed to exchange code for token: Invalid code'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
jasmine.any(Object)
);
});
it('should handle error when user data fetch fails', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
redirectUri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError(
'Failed to fetch Google Play Games Services user: Unauthorized'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.googleapis.com/games/v1/players/mockUserId',
jasmine.any(Object)
);
});
it('should handle error when user data is invalid', async function () {
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://www.googleapis.com/games/v1/players/mockUserId',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
playerId: 'anotherUserId',
}),
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
redirectUri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError(
'Invalid Google Play Games Services user data received.'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://oauth2.googleapis.com/token',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.googleapis.com/games/v1/players/mockUserId',
jasmine.any(Object)
);
});
it('should handle error when no code or access token is provided', async function () {
mockFetch();
const authData = {
id: 'mockUserId',
};
await expectAsync(Parse.User.logInWith('gpgames', { authData })).toBeRejectedWithError(
'gpgames code is required.'
);
expect(global.fetch).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,258 @@
const InstagramAdapter = require('../../../lib/Adapters/Auth/instagram').default;
describe('InstagramAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new InstagramAdapter.constructor();
adapter.clientId = 'validClientId';
adapter.clientSecret = 'validClientSecret';
adapter.redirectUri = 'https://example.com/callback';
});
describe('getAccessTokenFromCode', function () {
it('should fetch an access token successfully', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken',
}),
},
},
]);
const authData = { code: 'validCode' };
const token = await adapter.getAccessTokenFromCode(authData);
expect(token).toBe('mockAccessToken');
});
it('should throw an error if the response contains an error', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
error: 'invalid_grant',
error_description: 'Code is invalid',
}),
},
},
]);
const authData = { code: 'invalidCode' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Code is invalid'
);
});
});
describe('getUserFromAccessToken', function () {
it('should fetch user data successfully', async function () {
mockFetch([
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const accessToken = 'mockAccessToken';
const authData = { id: 'mockUserId' };
const user = await adapter.getUserFromAccessToken(accessToken, authData);
expect(user).toEqual({ id: 'mockUserId' });
});
it('should throw an error if user ID does not match authData', async function () {
mockFetch([
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'differentUserId',
}),
},
},
]);
const accessToken = 'mockAccessToken';
const authData = { id: 'mockUserId' };
await expectAsync(adapter.getUserFromAccessToken(accessToken, authData)).toBeRejectedWithError(
'Instagram auth is invalid for this user.'
);
});
});
describe('InstagramAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
instagram: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
redirectUri: 'https://example.com/callback',
},
},
});
});
it('should log in user successfully with valid code', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
};
const user = await Parse.User.logInWith('instagram', { authData });
expect(user.id).toBeDefined();
});
it('should handle error when access token exchange fails', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = {
code: 'invalidCode',
};
await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.')
);
});
it('should handle error when user data fetch fails', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
};
await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.')
);
});
it('should handle error when user data is invalid', async function () {
mockFetch([
{
url: 'https://api.instagram.com/oauth/access_token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://graph.instagram.com/me?fields=id&access_token=mockAccessToken123',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'differentUserId',
}),
},
},
]);
const authData = {
code: 'validCode',
id: 'mockUserId',
};
await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWithError(
'Instagram auth is invalid for this user.'
);
});
it('should handle error when no code or access token is provided', async function () {
mockFetch();
const authData = {
id: 'mockUserId',
};
await expectAsync(Parse.User.logInWith('instagram', { authData })).toBeRejectedWithError(
'Instagram code is required.'
);
});
});
});

View File

@@ -0,0 +1,309 @@
const LineAdapter = require('../../../lib/Adapters/Auth/line').default;
describe('LineAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new LineAdapter.constructor();
adapter.clientId = 'validClientId';
adapter.clientSecret = 'validClientSecret';
});
describe('getAccessTokenFromCode', function () {
it('should throw an error if code is missing in authData', async function () {
const authData = { redirect_uri: 'http://example.com' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Line auth is invalid for this user.'
);
});
it('should fetch an access token successfully', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken',
}),
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'http://example.com',
};
const token = await adapter.getAccessTokenFromCode(authData);
expect(token).toBe('mockAccessToken');
});
it('should throw an error if response is not ok', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
const authData = {
code: 'invalidCode',
redirect_uri: 'http://example.com',
};
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Failed to exchange code for token: Bad Request'
);
});
it('should throw an error if response contains an error object', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
error: 'invalid_grant',
error_description: 'Code is invalid',
}),
},
},
]);
const authData = {
code: 'invalidCode',
redirect_uri: 'http://example.com',
};
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Code is invalid'
);
});
});
describe('getUserFromAccessToken', function () {
it('should fetch user data successfully', async function () {
mockFetch([
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
userId: 'mockUserId',
displayName: 'mockDisplayName',
}),
},
},
]);
const accessToken = 'validAccessToken';
const user = await adapter.getUserFromAccessToken(accessToken);
expect(user).toEqual({
userId: 'mockUserId',
displayName: 'mockDisplayName',
});
});
it('should throw an error if response is not ok', async function () {
mockFetch([
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const accessToken = 'invalidAccessToken';
await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError(
'Failed to fetch Line user: Unauthorized'
);
});
it('should throw an error if user data is invalid', async function () {
mockFetch([
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({}),
},
},
]);
const accessToken = 'validAccessToken';
await expectAsync(adapter.getUserFromAccessToken(accessToken)).toBeRejectedWithError(
'Invalid Line user data received.'
);
});
});
describe('LineAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
line: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
},
},
});
});
it('should log in user successfully with valid code', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
userId: 'mockUserId',
displayName: 'mockDisplayName',
}),
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'http://example.com',
};
const user = await Parse.User.logInWith('line', { authData });
expect(user.id).toBeDefined();
});
it('should handle error when token exchange fails', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = {
code: 'invalidCode',
redirect_uri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError(
'Failed to exchange code for token: Invalid code'
);
});
it('should handle error when user data fetch fails', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError(
'Failed to fetch Line user: Unauthorized'
);
});
it('should handle error when user data is invalid', async function () {
mockFetch([
{
url: 'https://api.line.me/oauth2/v2.1/token',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.line.me/v2/profile',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({}),
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError(
'Invalid Line user data received.'
);
});
it('should handle error when no code is provided', async function () {
mockFetch();
const authData = {
redirect_uri: 'http://example.com',
};
await expectAsync(Parse.User.logInWith('line', { authData })).toBeRejectedWithError(
'Line code is required.'
);
});
});
});

View File

@@ -0,0 +1,312 @@
const LinkedInAdapter = require('../../../lib/Adapters/Auth/linkedin').default;
describe('LinkedInAdapter', function () {
let adapter;
const validOptions = {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: false,
};
beforeEach(function () {
adapter = new LinkedInAdapter.constructor();
});
describe('Test configuration errors', function () {
it('should throw error for missing options', function () {
const invalidOptions = [null, undefined, {}, { clientId: 'validClientId' }];
for (const options of invalidOptions) {
expect(() => {
adapter.validateOptions(options);
}).toThrow();
}
});
it('should validate options successfully with valid parameters', function () {
expect(() => {
adapter.validateOptions(validOptions);
}).not.toThrow();
expect(adapter.clientId).toBe(validOptions.clientId);
expect(adapter.clientSecret).toBe(validOptions.clientSecret);
expect(adapter.enableInsecureAuth).toBe(validOptions.enableInsecureAuth);
});
});
describe('Test beforeFind', function () {
it('should throw error for invalid payload', async function () {
adapter.enableInsecureAuth = true;
const payloads = [{}, { access_token: null }];
for (const payload of payloads) {
await expectAsync(adapter.beforeFind(payload)).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn auth is invalid for this user.')
);
}
});
it('should process secure payload and set auth data', async function () {
spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(
Promise.resolve('validToken')
);
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'validUserId' })
);
const authData = { code: 'validCode', redirect_uri: 'http://example.com', is_mobile_sdk: false };
await adapter.beforeFind(authData);
expect(authData.access_token).toBe('validToken');
expect(authData.id).toBe('validUserId');
});
it('should validate insecure auth and match user id', async function () {
adapter.enableInsecureAuth = true;
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'validUserId' })
);
const authData = { access_token: 'validToken', id: 'validUserId', is_mobile_sdk: false };
await expectAsync(adapter.beforeFind(authData)).toBeResolved();
});
it('should throw error if insecure auth user id does not match', async function () {
adapter.enableInsecureAuth = true;
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'invalidUserId' })
);
const authData = { access_token: 'validToken', id: 'validUserId', is_mobile_sdk: false };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith(
new Error('LinkedIn auth is invalid for this user.')
);
});
});
describe('Test getUserFromAccessToken', function () {
it('should fetch user successfully', async function () {
global.fetch = jasmine.createSpy().and.returnValue(
Promise.resolve({
ok: true,
json: () => Promise.resolve({ id: 'validUserId' }),
})
);
const user = await adapter.getUserFromAccessToken('validToken', false);
expect(global.fetch).toHaveBeenCalledWith('https://api.linkedin.com/v2/me', {
headers: {
Authorization: `Bearer validToken`,
'x-li-format': 'json',
'x-li-src': undefined,
},
});
expect(user).toEqual({ id: 'validUserId' });
});
it('should throw error for invalid response', async function () {
global.fetch = jasmine.createSpy().and.returnValue(
Promise.resolve({ ok: false })
);
await expectAsync(adapter.getUserFromAccessToken('invalidToken', false)).toBeRejectedWith(
new Error('LinkedIn API request failed.')
);
});
});
describe('Test getAccessTokenFromCode', function () {
it('should fetch token successfully', async function () {
global.fetch = jasmine.createSpy().and.returnValue(
Promise.resolve({
ok: true,
json: () => Promise.resolve({ access_token: 'validToken' }),
})
);
const tokenResponse = await adapter.getAccessTokenFromCode('validCode', 'http://example.com');
expect(global.fetch).toHaveBeenCalledWith('https://www.linkedin.com/oauth/v2/accessToken', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: jasmine.any(URLSearchParams),
});
expect(tokenResponse).toEqual('validToken');
});
it('should throw error for invalid response', async function () {
global.fetch = jasmine.createSpy().and.returnValue(
Promise.resolve({ ok: false })
);
await expectAsync(
adapter.getAccessTokenFromCode('invalidCode', 'http://example.com')
).toBeRejectedWith(new Error('LinkedIn API request failed.'));
});
});
describe('Test validate methods', function () {
const authData = { id: 'validUserId', access_token: 'validToken' };
it('validateLogin should return user id', function () {
const result = adapter.validateLogin(authData);
expect(result).toEqual({ id: 'validUserId' });
});
it('validateSetUp should return user id', function () {
const result = adapter.validateSetUp(authData);
expect(result).toEqual({ id: 'validUserId' });
});
it('validateUpdate should return user id', function () {
const result = adapter.validateUpdate(authData);
expect(result).toEqual({ id: 'validUserId' });
});
it('afterFind should return user id', function () {
const result = adapter.afterFind(authData);
expect(result).toEqual({ id: 'validUserId' });
});
});
describe('LinkedInAdapter E2E Test', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
linkedin: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
},
},
});
});
it('should log in user using LinkedIn adapter successfully (secure)', async function () {
mockFetch([
{
url: 'https://www.linkedin.com/oauth/v2/accessToken',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.linkedin.com/v2/me',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://example.com/callback' };
const user = await Parse.User.logInWith('linkedin', { authData });
expect(user.id).toBeDefined();
expect(global.fetch).toHaveBeenCalledWith(
'https://www.linkedin.com/oauth/v2/accessToken',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.linkedin.com/v2/me',
jasmine.any(Object)
);
});
it('should handle error when LinkedIn returns invalid user data', async function () {
mockFetch([
{
url: 'https://www.linkedin.com/oauth/v2/accessToken',
method: 'POST',
response: {
ok: true,
json: () =>
Promise.resolve({
access_token: 'mockAccessToken123',
}),
},
},
{
url: 'https://api.linkedin.com/v2/me',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://example.com/callback' };
await expectAsync(Parse.User.logInWith('linkedin', { authData })).toBeRejectedWithError(
'LinkedIn API request failed.'
);
expect(global.fetch).toHaveBeenCalledWith(
'https://www.linkedin.com/oauth/v2/accessToken',
jasmine.any(Object)
);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.linkedin.com/v2/me',
jasmine.any(Object)
);
});
it('secure does not support insecure payload if not enabled', async function () {
mockFetch();
const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' };
await expectAsync(Parse.User.logInWith('linkedin', { authData })).toBeRejectedWithError(
'LinkedIn code is required.'
);
expect(global.fetch).not.toHaveBeenCalled();
});
it('insecure mode supports insecure payload if enabled', async function () {
await reconfigureServer({
auth: {
linkedin: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: true,
},
},
});
mockFetch([
{
url: 'https://api.linkedin.com/v2/me',
method: 'GET',
response: {
ok: true,
json: () =>
Promise.resolve({
id: 'mockUserId',
}),
},
},
]);
const authData = { id: 'mockUserId', access_token: 'mockAccessToken123' };
const user = await Parse.User.logInWith('linkedin', { authData });
expect(user.id).toBeDefined();
expect(global.fetch).toHaveBeenCalledWith(
'https://api.linkedin.com/v2/me',
jasmine.any(Object)
);
});
});
});

View File

@@ -0,0 +1,307 @@
const MicrosoftAdapter = require('../../../lib/Adapters/Auth/microsoft').default;
describe('MicrosoftAdapter', function () {
let adapter;
const validOptions = {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: false,
};
beforeEach(function () {
adapter = new MicrosoftAdapter.constructor();
});
describe('Test configuration errors', function () {
it('should throw error for missing options', function () {
const invalidOptions = [null, undefined, {}, { clientId: 'validClientId' }];
for (const options of invalidOptions) {
expect(() => {
adapter.validateOptions(options);
}).toThrow();
}
});
it('should validate options successfully with valid parameters', function () {
expect(() => {
adapter.validateOptions(validOptions);
}).not.toThrow();
expect(adapter.clientId).toBe(validOptions.clientId);
expect(adapter.clientSecret).toBe(validOptions.clientSecret);
expect(adapter.enableInsecureAuth).toBe(validOptions.enableInsecureAuth);
});
});
describe('Test getUserFromAccessToken', function () {
it('should fetch user successfully', async function () {
mockFetch([
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ id: 'validUserId' }),
},
},
]);
const user = await adapter.getUserFromAccessToken('validToken');
expect(global.fetch).toHaveBeenCalledWith('https://graph.microsoft.com/v1.0/me', {
headers: {
Authorization: 'Bearer validToken',
},
method: 'GET',
});
expect(user).toEqual({ id: 'validUserId' });
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: { ok: false },
},
]);
await expectAsync(adapter.getUserFromAccessToken('invalidToken')).toBeRejectedWith(
new Error('Microsoft API request failed.')
);
});
});
describe('Test getAccessTokenFromCode', function () {
it('should fetch token successfully', async function () {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validToken' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com' };
const token = await adapter.getAccessTokenFromCode(authData);
expect(global.fetch).toHaveBeenCalledWith('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: jasmine.any(URLSearchParams),
});
expect(token).toEqual('validToken');
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: { ok: false },
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith(
new Error('Microsoft API request failed.')
);
});
});
describe('Test secure authentication flow', function () {
it('should exchange code for access token and fetch user data', async function () {
spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(Promise.resolve('validToken'));
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(Promise.resolve({ id: 'validUserId' }));
const authData = { code: 'validCode', redirect_uri: 'http://example.com' };
await adapter.beforeFind(authData);
expect(authData.access_token).toBe('validToken');
expect(authData.id).toBe('validUserId');
});
it('should throw error if user data cannot be fetched', async function () {
spyOn(adapter, 'getAccessTokenFromCode').and.returnValue(Promise.resolve('validToken'));
spyOn(adapter, 'getUserFromAccessToken').and.throwError('Microsoft API request failed.');
const authData = { code: 'validCode', redirect_uri: 'http://example.com' };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith(
new Error('Microsoft API request failed.')
);
});
});
describe('Test insecure authentication flow', function () {
beforeEach(function () {
adapter.enableInsecureAuth = true;
});
it('should validate insecure auth and match user id', async function () {
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'validUserId' })
);
const authData = { access_token: 'validToken', id: 'validUserId' };
await expectAsync(adapter.beforeFind(authData)).toBeResolved();
});
it('should throw error if insecure auth user id does not match', async function () {
spyOn(adapter, 'getUserFromAccessToken').and.returnValue(
Promise.resolve({ id: 'invalidUserId' })
);
const authData = { access_token: 'validToken', id: 'validUserId' };
await expectAsync(adapter.beforeFind(authData)).toBeRejectedWith(
new Error('Microsoft auth is invalid for this user.')
);
});
});
describe('MicrosoftAdapter E2E Tests', () => {
beforeEach(async () => {
// Simulate reconfiguring the server with Microsoft auth options
await reconfigureServer({
auth: {
microsoft: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: false,
},
},
});
});
it('should authenticate user successfully using MicrosoftAdapter', async () => {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken' }),
},
},
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ id: 'user123' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
const user = await Parse.User.logInWith('microsoft', { authData });
expect(user.id).toBeDefined();
});
it('should handle invalid code error gracefully', async () => {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: { ok: false, statusText: 'Invalid code' },
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError(
'Microsoft API request failed.'
);
});
it('should handle error when fetching user data fails', async () => {
mockFetch([
{
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken' }),
},
},
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: { ok: false, statusText: 'Unauthorized' },
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError(
'Microsoft API request failed.'
);
});
it('should allow insecure auth when enabled', async () => {
mockFetch([
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({
id: 'user123',
}),
},
},
])
await reconfigureServer({
auth: {
microsoft: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: true,
},
},
});
const authData = { access_token: 'validAccessToken', id: 'user123' };
const user = await Parse.User.logInWith('microsoft', { authData });
expect(user.id).toBeDefined();
});
it('should reject insecure auth when user id does not match', async () => {
mockFetch([
{
url: 'https://graph.microsoft.com/v1.0/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({
id: 'incorrectUser',
}),
},
},
])
await reconfigureServer({
auth: {
microsoft: {
clientId: 'validClientId',
clientSecret: 'validClientSecret',
enableInsecureAuth: true,
},
},
});
const authData = { access_token: 'validAccessToken', id: 'incorrectUserId' };
await expectAsync(Parse.User.logInWith('microsoft', { authData })).toBeRejectedWithError(
'Microsoft auth is invalid for this user.'
);
});
});
});

View File

@@ -0,0 +1,305 @@
const OAuth2Adapter = require('../../../lib/Adapters/Auth/oauth2').default;
describe('OAuth2Adapter', () => {
let adapter;
const validOptions = {
tokenIntrospectionEndpointUrl: 'https://provider.com/introspect',
useridField: 'sub',
appidField: 'aud',
appIds: ['valid-app-id'],
authorizationHeader: 'Bearer validAuthToken',
};
beforeEach(() => {
adapter = new OAuth2Adapter.constructor();
adapter.validateOptions(validOptions);
});
describe('validateAppId', () => {
it('should validate app ID successfully', async () => {
const authData = { access_token: 'validAccessToken' };
const mockResponse = {
[validOptions.appidField]: 'valid-app-id',
};
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAppId(validOptions.appIds, authData, validOptions)
).toBeResolved();
});
it('should throw an error if app ID is invalid', async () => {
const authData = { access_token: 'validAccessToken' };
const mockResponse = {
[validOptions.appidField]: 'invalid-app-id',
};
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAppId(validOptions.appIds, authData, validOptions)
).toBeRejectedWithError('OAuth2: Invalid app ID.');
});
});
describe('validateAuthData', () => {
it('should validate auth data successfully', async () => {
const authData = { id: 'user-id', access_token: 'validAccessToken' };
const mockResponse = {
active: true,
[validOptions.useridField]: 'user-id',
};
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAuthData(authData, null, validOptions)
).toBeResolvedTo({});
});
it('should throw an error if the token is inactive', async () => {
const authData = { id: 'user-id', access_token: 'validAccessToken' };
const mockResponse = { active: false };
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAuthData(authData, null, validOptions)
).toBeRejectedWith(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.'));
});
it('should throw an error if user ID does not match', async () => {
const authData = { id: 'user-id', access_token: 'validAccessToken' };
const mockResponse = {
active: true,
[validOptions.useridField]: 'different-user-id',
};
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
await expectAsync(
adapter.validateAuthData(authData, null, validOptions)
).toBeRejectedWithError('OAuth2 access token is invalid for this user.');
});
});
describe('requestTokenInfo', () => {
it('should fetch token info successfully', async () => {
const mockResponse = { active: true };
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
const result = await adapter.requestTokenInfo(
'validAccessToken',
validOptions
);
expect(result).toEqual(mockResponse);
});
it('should throw an error if the introspection endpoint URL is missing', async () => {
const options = { ...validOptions, tokenIntrospectionEndpointUrl: null };
expect(
() => adapter.validateOptions(options)
).toThrow(new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.'));
});
it('should throw an error if the response is not ok', async () => {
mockFetch([
{
url: validOptions.tokenIntrospectionEndpointUrl,
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
await expectAsync(
adapter.requestTokenInfo('invalidAccessToken')
).toBeRejectedWithError('OAuth2 token introspection request failed.');
});
});
describe('OAuth2Adapter E2E Tests', () => {
beforeEach(async () => {
// Simulate reconfiguring the server with OAuth2 auth options
await reconfigureServer({
auth: {
mockOauth: {
tokenIntrospectionEndpointUrl: 'https://provider.com/introspect',
useridField: 'sub',
appidField: 'aud',
appIds: ['valid-app-id'],
authorizationHeader: 'Bearer validAuthToken',
oauth2: true
},
},
});
});
it('should validate and authenticate user successfully', async () => {
mockFetch([
{
url: 'https://provider.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'user123',
aud: 'valid-app-id',
}),
},
},
]);
const authData = { access_token: 'validAccessToken', id: 'user123' };
const user = await Parse.User.logInWith('mockOauth', { authData });
expect(user.id).toBeDefined();
expect(user.get('authData').mockOauth.id).toEqual('user123');
});
it('should reject authentication for inactive token', async () => {
mockFetch([
{
url: 'https://provider.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ active: false, aud: ['valid-app-id'] }),
},
},
]);
const authData = { access_token: 'inactiveToken', id: 'user123' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')
);
});
it('should reject authentication for mismatched user ID', async () => {
mockFetch([
{
url: 'https://provider.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'different-user',
aud: 'valid-app-id',
}),
},
},
]);
const authData = { access_token: 'validAccessToken', id: 'user123' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.')
);
});
it('should reject authentication for invalid app ID', async () => {
mockFetch([
{
url: 'https://provider.com/introspect',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({
active: true,
sub: 'user123',
aud: 'invalid-app-id',
}),
},
},
]);
const authData = { access_token: 'validAccessToken', id: 'user123' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWithError(
'OAuth2: Invalid app ID.'
);
});
it('should handle error when token introspection endpoint is missing', async () => {
await reconfigureServer({
auth: {
mockOauth: {
tokenIntrospectionEndpointUrl: null,
useridField: 'sub',
appidField: 'aud',
appIds: ['valid-app-id'],
authorizationHeader: 'Bearer validAuthToken',
oauth2: true
},
},
});
const authData = { access_token: 'validAccessToken', id: 'user123' };
await expectAsync(Parse.User.logInWith('mockOauth', { authData })).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.')
);
});
});
});

View File

@@ -0,0 +1,252 @@
const QqAdapter = require('../../../lib/Adapters/Auth/qq').default;
describe('QqAdapter', () => {
let adapter;
beforeEach(() => {
adapter = new QqAdapter.constructor();
});
describe('getUserFromAccessToken', () => {
it('should fetch user data successfully', async () => {
const mockResponse = `callback({"client_id":"validAppId","openid":"user123"})`;
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: true,
text: () => Promise.resolve(mockResponse),
},
},
]);
const result = await adapter.getUserFromAccessToken('validAccessToken');
expect(result).toEqual({ client_id: 'validAppId', openid: 'user123' });
});
it('should throw an error if the API request fails', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
await expectAsync(
adapter.getUserFromAccessToken('invalidAccessToken')
).toBeRejectedWithError('qq API request failed.');
});
});
describe('getAccessTokenFromCode', () => {
it('should fetch access token successfully', async () => {
const mockResponse = `callback({"access_token":"validAccessToken","expires_in":3600,"refresh_token":"refreshToken"})`;
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: true,
text: () => Promise.resolve(mockResponse),
},
},
]);
const result = await adapter.getAccessTokenFromCode({
code: 'validCode',
redirect_uri: 'https://your-redirect-uri.com/callback',
});
expect(result).toBe('validAccessToken');
});
it('should throw an error if the API request fails', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
await expectAsync(
adapter.getAccessTokenFromCode({
code: 'invalidCode',
redirect_uri: 'https://your-redirect-uri.com/callback',
})
).toBeRejectedWithError('qq API request failed.');
});
});
describe('parseResponseData', () => {
it('should parse valid callback response data', () => {
const response = `callback({"key":"value"})`;
const result = adapter.parseResponseData(response);
expect(result).toEqual({ key: 'value' });
});
it('should throw an error if the response data is invalid', () => {
const response = 'invalid response';
expect(() => adapter.parseResponseData(response)).toThrowError(
'qq auth is invalid for this user.'
);
});
});
describe('QqAdapter E2E Test', () => {
beforeEach(async () => {
await reconfigureServer({
auth: {
qq: {
clientId: 'validAppId',
clientSecret: 'validAppSecret',
},
},
});
});
it('should log in user using Qq adapter successfully', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"access_token":"mockAccessToken","expires_in":3600})`
),
},
},
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"client_id":"validAppId","openid":"user123"})`
),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' };
const user = await Parse.User.logInWith('qq', { authData });
expect(user.id).toBeDefined();
});
it('should handle error when Qq returns invalid code', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: false,
statusText: 'Invalid code',
},
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'https://your-redirect-uri.com/callback' };
await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError(
'qq API request failed.'
);
});
it('should handle error when Qq returns invalid user data', async () => {
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"access_token":"mockAccessToken","expires_in":3600})`
),
},
},
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' };
await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError(
'qq API request failed.'
);
});
it('e2e secure does not support insecure payload', async () => {
mockFetch();
const authData = { id: 'mockUserId', access_token: 'mockAccessToken' };
await expectAsync(Parse.User.logInWith('qq', { authData })).toBeRejectedWithError(
'qq code is required.'
);
});
it('e2e insecure does support secure payload', async () => {
await reconfigureServer({
auth: {
qq: {
appId: 'validAppId',
appSecret: 'validAppSecret',
enableInsecureAuth: true,
},
},
});
mockFetch([
{
url: 'https://graph.qq.com/oauth2.0/token',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"access_token":"mockAccessToken","expires_in":3600})`
),
},
},
{
url: 'https://graph.qq.com/oauth2.0/me',
method: 'GET',
response: {
ok: true,
text: () =>
Promise.resolve(
`callback({"client_id":"validAppId","openid":"user123"})`
),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'https://your-redirect-uri.com/callback' };
const user = await Parse.User.logInWith('qq', { authData });
expect(user.id).toBeDefined();
});
});
});

View File

@@ -0,0 +1,113 @@
const SpotifyAdapter = require('../../../lib/Adapters/Auth/spotify').default;
describe('SpotifyAdapter', () => {
let adapter;
beforeEach(() => {
adapter = new SpotifyAdapter.constructor();
});
describe('getUserFromAccessToken', () => {
it('should fetch user data successfully', async () => {
const mockResponse = {
id: 'spotifyUser123',
};
mockFetch([
{
url: 'https://api.spotify.com/v1/me',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
const result = await adapter.getUserFromAccessToken('validAccessToken');
expect(result).toEqual({ id: 'spotifyUser123' });
});
it('should throw an error if the API request fails', async () => {
mockFetch([
{
url: 'https://api.spotify.com/v1/me',
method: 'GET',
response: {
ok: false,
statusText: 'Unauthorized',
},
},
]);
await expectAsync(adapter.getUserFromAccessToken('invalidAccessToken')).toBeRejectedWithError(
'Spotify API request failed.'
);
});
});
describe('getAccessTokenFromCode', () => {
it('should fetch access token successfully', async () => {
const mockResponse = {
access_token: 'validAccessToken',
expires_in: 3600,
refresh_token: 'refreshToken',
};
mockFetch([
{
url: 'https://accounts.spotify.com/api/token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve(mockResponse),
},
},
]);
const authData = {
code: 'validCode',
redirect_uri: 'https://your-redirect-uri.com/callback',
code_verifier: 'validCodeVerifier',
};
const result = await adapter.getAccessTokenFromCode(authData);
expect(result).toEqual(mockResponse);
});
it('should throw an error if authData is missing required fields', async () => {
const authData = {
redirect_uri: 'https://your-redirect-uri.com/callback',
};
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Spotify auth configuration authData.code and/or authData.redirect_uri and/or authData.code_verifier.'
);
});
it('should throw an error if the API request fails', async () => {
mockFetch([
{
url: 'https://accounts.spotify.com/api/token',
method: 'POST',
response: {
ok: false,
statusText: 'Bad Request',
},
},
]);
const authData = {
code: 'invalidCode',
redirect_uri: 'https://your-redirect-uri.com/callback',
code_verifier: 'invalidCodeVerifier',
};
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWithError(
'Spotify API request failed.'
);
});
});
});

View File

@@ -0,0 +1,120 @@
const TwitterAuthAdapter = require('../../../lib/Adapters/Auth/twitter').default;
describe('TwitterAuthAdapter', function () {
let adapter;
const validOptions = {
consumer_key: 'validConsumerKey',
consumer_secret: 'validConsumerSecret',
};
beforeEach(function () {
adapter = new TwitterAuthAdapter.constructor();
});
describe('Test configuration errors', function () {
it('should throw an error when options are missing', function () {
expect(() => adapter.validateOptions()).toThrowError('Twitter auth options are required.');
});
it('should throw an error when consumer_key and consumer_secret are missing for secure auth', function () {
const options = { enableInsecureAuth: false };
expect(() => adapter.validateOptions(options)).toThrowError(
'Consumer key and secret are required for secure Twitter auth.'
);
});
it('should not throw an error when valid options are provided', function () {
expect(() => adapter.validateOptions(validOptions)).not.toThrow();
});
});
describe('Validate Insecure Auth', function () {
it('should throw an error if oauth_token or oauth_token_secret are missing', async function () {
const authData = { oauth_token: 'validToken' }; // Missing oauth_token_secret
await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeRejectedWithError(
'Twitter insecure auth requires oauth_token and oauth_token_secret.'
);
});
it('should validate insecure auth successfully when data matches', async function () {
spyOn(adapter, 'request').and.returnValue(
Promise.resolve({
json: () => Promise.resolve({ id: 'validUserId' }),
})
);
const authData = {
id: 'validUserId',
oauth_token: 'validToken',
oauth_token_secret: 'validSecret',
};
await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeResolved();
});
it('should throw an error when user ID does not match', async function () {
spyOn(adapter, 'request').and.returnValue(
Promise.resolve({
json: () => Promise.resolve({ id: 'invalidUserId' }),
})
);
const authData = {
id: 'validUserId',
oauth_token: 'validToken',
oauth_token_secret: 'validSecret',
};
await expectAsync(adapter.validateInsecureAuth(authData, validOptions)).toBeRejectedWithError(
'Twitter auth is invalid for this user.'
);
});
});
describe('End-to-End Tests', function () {
beforeEach(async function () {
await reconfigureServer({
auth: {
twitter: validOptions,
}
})
});
it('should authenticate user successfully using validateAuthData', async function () {
spyOn(adapter, 'exchangeAccessToken').and.returnValue(
Promise.resolve({ oauth_token: 'validToken', user_id: 'validUserId' })
);
const authData = {
oauth_token: 'validToken',
oauth_verifier: 'validVerifier',
};
await expectAsync(adapter.validateAuthData(authData, validOptions)).toBeResolved();
expect(authData.id).toBe('validUserId');
expect(authData.auth_token).toBe('validToken');
});
it('should handle multiple configurations and validate successfully', async function () {
const authData = {
consumer_key: 'validConsumerKey',
oauth_token: 'validToken',
oauth_token_secret: 'validSecret',
};
const optionsArray = [
{ consumer_key: 'invalidKey', consumer_secret: 'invalidSecret' },
validOptions,
];
const selectedOption = adapter.handleMultipleConfigurations(authData, optionsArray);
expect(selectedOption).toEqual(validOptions);
});
it('should throw an error when no matching configuration is found', function () {
const authData = { consumer_key: 'missingKey' };
const optionsArray = [validOptions];
expect(() => adapter.handleMultipleConfigurations(authData, optionsArray)).toThrowError(
'Twitter auth is invalid for this user.'
);
});
});
});

View File

@@ -0,0 +1,234 @@
const WeChatAdapter = require('../../../lib/Adapters/Auth/wechat').default;
describe('WeChatAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new WeChatAdapter.constructor();
});
describe('Test getUserFromAccessToken', function () {
it('should fetch user successfully', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ errcode: 0, id: 'validUserId' }),
},
},
]);
const user = await adapter.getUserFromAccessToken('validToken', { id: 'validOpenId' });
expect(global.fetch).toHaveBeenCalledWith(
'https://api.weixin.qq.com/sns/auth?access_token=validToken&openid=validOpenId'
);
expect(user).toEqual({ errcode: 0, id: 'validUserId' });
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=invalidToken&openid=undefined',
method: 'GET',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40013, errmsg: 'Invalid token' }),
},
},
]);
await expectAsync(adapter.getUserFromAccessToken('invalidToken', 'invalidOpenId')).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
});
describe('Test getAccessTokenFromCode', function () {
it('should fetch access token successfully', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validToken', errcode: 0 }),
},
},
]);
adapter.validateOptions({ clientId: 'validAppId', clientSecret: 'validAppSecret' });
const authData = { code: 'validCode' };
const token = await adapter.getAccessTokenFromCode(authData);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code'
);
expect(token).toEqual('validToken');
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=invalidCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40029, errmsg: 'Invalid code' }),
},
},
]);
adapter.validateOptions({ clientId: 'validAppId', clientSecret: 'validAppSecret' });
const authData = { code: 'invalidCode' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
});
describe('WeChatAdapter E2E Tests', function () {
beforeEach(async () => {
await reconfigureServer({
auth: {
wechat: {
clientId: 'validAppId',
clientSecret: 'validAppSecret',
enableInsecureAuth: false,
},
},
});
});
it('should authenticate user successfully using WeChatAdapter', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken', openid: 'user123', errcode: 0 }),
},
},
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ errcode: 0, id: 'user123' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
const user = await Parse.User.logInWith('wechat', { authData });
expect(user.id).toBeDefined();
});
it('should handle invalid code error gracefully', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=invalidCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40029, errmsg: 'Invalid code' }),
},
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
it('should handle error when fetching user data fails', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=validAppId&secret=validAppSecret&code=validCode&grant_type=authorization_code',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken', openid: 'user123', errcode: 0 }),
},
},
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123',
method: 'GET',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40013, errmsg: 'Invalid token' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
it('should allow insecure auth when enabled', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=user123',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ errcode: 0, id: 'user123' }),
},
},
]);
await reconfigureServer({
auth: {
wechat: {
appId: 'validAppId',
appSecret: 'validAppSecret',
enableInsecureAuth: true,
},
},
});
const authData = { access_token: 'validAccessToken', id: 'user123' };
const user = await Parse.User.logInWith('wechat', { authData });
expect(user.id).toBeDefined();
});
it('should reject insecure auth when user id does not match', async function () {
mockFetch([
{
url: 'https://api.weixin.qq.com/sns/auth?access_token=validAccessToken&openid=incorrectUserId',
method: 'GET',
response: {
ok: true,
json: () => Promise.resolve({ errcode: 0, id: 'incorrectUser' }),
},
},
]);
await reconfigureServer({
auth: {
wechat: {
appId: 'validAppId',
appSecret: 'validAppSecret',
enableInsecureAuth: true,
},
},
});
const authData = { access_token: 'validAccessToken', id: 'incorrectUserId' };
await expectAsync(Parse.User.logInWith('wechat', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'WeChat auth is invalid for this user.' })
);
});
});
});

View File

@@ -0,0 +1,204 @@
const WeiboAdapter = require('../../../lib/Adapters/Auth/weibo').default;
describe('WeiboAdapter', function () {
let adapter;
beforeEach(function () {
adapter = new WeiboAdapter.constructor();
});
describe('Test configuration errors', function () {
it('should throw error if code or redirect_uri is missing', async function () {
const invalidAuthData = [
{},
{ code: 'validCode' },
{ redirect_uri: 'http://example.com/callback' },
];
for (const authData of invalidAuthData) {
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith(
jasmine.objectContaining({
message: 'Weibo auth requires code and redirect_uri to be sent.',
})
);
}
});
});
describe('Test getUserFromAccessToken', function () {
it('should fetch user successfully', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/get_token_info',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ uid: 'validUserId' }),
},
},
]);
const authData = { id: 'validUserId' };
const user = await adapter.getUserFromAccessToken('validToken', authData);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.weibo.com/oauth2/get_token_info',
jasmine.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
expect(user).toEqual({ id: 'validUserId' });
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/get_token_info',
method: 'POST',
response: {
ok: false,
json: () => Promise.resolve({}),
},
},
]);
const authData = { id: 'invalidUserId' };
await expectAsync(adapter.getUserFromAccessToken('invalidToken', authData)).toBeRejectedWith(
jasmine.objectContaining({
message: 'Weibo auth is invalid for this user.',
})
);
});
});
describe('Test getAccessTokenFromCode', function () {
it('should fetch access token successfully', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validToken', uid: 'validUserId' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
const token = await adapter.getAccessTokenFromCode(authData);
expect(global.fetch).toHaveBeenCalledWith(
'https://api.weibo.com/oauth2/access_token',
jasmine.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
);
expect(token).toEqual('validToken');
});
it('should throw error for invalid response', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40029 }),
},
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(adapter.getAccessTokenFromCode(authData)).toBeRejectedWith(
jasmine.objectContaining({
message: 'Weibo auth is invalid for this user.',
})
);
});
});
describe('WeiboAdapter E2E Tests', function () {
beforeEach(async () => {
await reconfigureServer({
auth: {
weibo: {
clientId: 'validAppId',
clientSecret: 'validAppSecret',
},
}
});
});
it('should authenticate user successfully using WeiboAdapter', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken', uid: 'user123' }),
},
},
{
url: 'https://api.weibo.com/oauth2/get_token_info',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ uid: 'user123' }),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
const user = await Parse.User.logInWith('weibo', { authData });
expect(user.id).toBeDefined();
});
it('should handle invalid code error gracefully', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: false,
json: () => Promise.resolve({ errcode: 40029 }),
},
},
]);
const authData = { code: 'invalidCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('weibo', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'Weibo auth is invalid for this user.' })
);
});
it('should handle error when fetching user data fails', async function () {
mockFetch([
{
url: 'https://api.weibo.com/oauth2/access_token',
method: 'POST',
response: {
ok: true,
json: () => Promise.resolve({ access_token: 'validAccessToken', uid: 'user123' }),
},
},
{
url: 'https://api.weibo.com/oauth2/get_token_info',
method: 'POST',
response: {
ok: false,
json: () => Promise.resolve({}),
},
},
]);
const authData = { code: 'validCode', redirect_uri: 'http://example.com/callback' };
await expectAsync(Parse.User.logInWith('weibo', { authData })).toBeRejectedWith(
jasmine.objectContaining({ message: 'Weibo auth is invalid for this user.' })
);
});
});
});

View File

@@ -3,99 +3,8 @@ const Config = require('../lib/Config');
const defaultColumns = require('../lib/Controllers/SchemaController').defaultColumns; const defaultColumns = require('../lib/Controllers/SchemaController').defaultColumns;
const authenticationLoader = require('../lib/Adapters/Auth'); const authenticationLoader = require('../lib/Adapters/Auth');
const path = require('path'); const path = require('path');
const responses = {
gpgames: { playerId: 'userId' },
instagram: { id: 'userId' },
janrainengage: { stat: 'ok', profile: { identifier: 'userId' } },
janraincapture: { stat: 'ok', result: 'userId' },
line: { userId: 'userId' },
vkontakte: { response: [{ id: 'userId' }] },
google: { sub: 'userId' },
wechat: { errcode: 0 },
weibo: { uid: 'userId' },
qq: 'callback( {"openid":"userId"} );', // yes it's like that, run eval in the client :P
phantauth: { sub: 'userId' },
microsoft: { id: 'userId', mail: 'userMail' },
};
describe('AuthenticationProviders', function () { describe('AuthenticationProviders', function () {
[
'apple',
'gcenter',
'gpgames',
'facebook',
'github',
'instagram',
'google',
'linkedin',
'meetup',
'twitter',
'janrainengage',
'janraincapture',
'line',
'vkontakte',
'qq',
'spotify',
'wechat',
'weibo',
'phantauth',
'microsoft',
'keycloak',
].map(function (providerName) {
it('Should validate structure of ' + providerName, done => {
const provider = require('../lib/Adapters/Auth/' + providerName);
jequal(typeof provider.validateAuthData, 'function');
jequal(typeof provider.validateAppId, 'function');
const validateAuthDataPromise = provider.validateAuthData({}, {});
const validateAppIdPromise = provider.validateAppId('app', 'key', {});
jequal(validateAuthDataPromise.constructor, Promise.prototype.constructor);
jequal(validateAppIdPromise.constructor, Promise.prototype.constructor);
validateAuthDataPromise.then(
() => {},
() => {}
);
validateAppIdPromise.then(
() => {},
() => {}
);
done();
});
it(`should provide the right responses for adapter ${providerName}`, async () => {
const noResponse = ['twitter', 'apple', 'gcenter', 'google', 'keycloak'];
if (noResponse.includes(providerName)) {
return;
}
spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'get').and.callFake(options => {
if (
options ===
'https://oauth.vk.com/access_token?client_id=appId&client_secret=appSecret&v=5.123&grant_type=client_credentials' ||
options ===
'https://oauth.vk.com/access_token?client_id=appId&client_secret=appSecret&v=5.124&grant_type=client_credentials'
) {
return {
access_token: 'access_token',
};
}
return Promise.resolve(responses[providerName] || { id: 'userId' });
});
spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'request').and.callFake(() => {
return Promise.resolve(responses[providerName] || { id: 'userId' });
});
const provider = require('../lib/Adapters/Auth/' + providerName);
let params = {};
if (providerName === 'vkontakte') {
params = {
appIds: 'appId',
appSecret: 'appSecret',
};
await provider.validateAuthData({ id: 'userId' }, params);
params.appVersion = '5.123';
}
await provider.validateAuthData({ id: 'userId' }, params);
});
});
const getMockMyOauthProvider = function () { const getMockMyOauthProvider = function () {
return { return {
authData: { authData: {
@@ -568,46 +477,6 @@ describe('AuthenticationProviders', function () {
}); });
}); });
describe('instagram auth adapter', () => {
const instagram = require('../lib/Adapters/Auth/instagram');
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
it('should use default api', async () => {
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ data: { id: 'userId' } });
});
await instagram.validateAuthData({ id: 'userId', access_token: 'the_token' }, {});
expect(httpsRequest.get).toHaveBeenCalledWith(
'https://graph.instagram.com/me?fields=id&access_token=the_token'
);
});
it('response object without data child', async () => {
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ id: 'userId' });
});
await instagram.validateAuthData({ id: 'userId', access_token: 'the_token' }, {});
expect(httpsRequest.get).toHaveBeenCalledWith(
'https://graph.instagram.com/me?fields=id&access_token=the_token'
);
});
it('should pass in api url', async () => {
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ data: { id: 'userId' } });
});
await instagram.validateAuthData(
{
id: 'userId',
access_token: 'the_token',
apiURL: 'https://new-api.instagram.com/v1/',
},
{}
);
expect(httpsRequest.get).toHaveBeenCalledWith(
'https://new-api.instagram.com/v1/me?fields=id&access_token=the_token'
);
});
});
describe('google auth adapter', () => { describe('google auth adapter', () => {
const google = require('../lib/Adapters/Auth/google'); const google = require('../lib/Adapters/Auth/google');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
@@ -730,35 +599,6 @@ describe('google auth adapter', () => {
}); });
}); });
describe('google play games service auth', () => {
const gpgames = require('../lib/Adapters/Auth/gpgames');
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
it('validateAuthData should pass validation', async () => {
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ playerId: 'userId' });
});
await gpgames.validateAuthData({
id: 'userId',
access_token: 'access_token',
});
});
it('validateAuthData should throw error', async () => {
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ playerId: 'invalid' });
});
try {
await gpgames.validateAuthData({
id: 'userId',
access_token: 'access_token',
});
} catch (e) {
expect(e.message).toBe('Google Play Games Services - authData is invalid for this user.');
}
});
});
describe('keycloak auth adapter', () => { describe('keycloak auth adapter', () => {
const keycloak = require('../lib/Adapters/Auth/keycloak'); const keycloak = require('../lib/Adapters/Auth/keycloak');
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
@@ -987,433 +827,6 @@ describe('keycloak auth adapter', () => {
}); });
}); });
describe('oauth2 auth adapter', () => {
const oauth2 = require('../lib/Adapters/Auth/oauth2');
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
it('properly loads OAuth2 adapter via the "oauth2" option', () => {
const options = {
oauth2Authentication: {
oauth2: true,
},
};
const loadedAuthAdapter = authenticationLoader.loadAuthAdapter('oauth2Authentication', options);
expect(loadedAuthAdapter.adapter).toEqual(oauth2);
});
it('properly loads OAuth2 adapter with options', () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
useridField: 'sub',
appidField: 'appId',
appIds: ['a', 'b'],
authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=',
debug: true,
},
};
const loadedAuthAdapter = authenticationLoader.loadAuthAdapter('oauth2Authentication', options);
const appIds = loadedAuthAdapter.appIds;
const providerOptions = loadedAuthAdapter.providerOptions;
expect(providerOptions.tokenIntrospectionEndpointUrl).toEqual('https://example.com/introspect');
expect(providerOptions.useridField).toEqual('sub');
expect(providerOptions.appidField).toEqual('appId');
expect(appIds).toEqual(['a', 'b']);
expect(providerOptions.authorizationHeader).toEqual('Basic dXNlcm5hbWU6cGFzc3dvcmQ=');
expect(providerOptions.debug).toEqual(true);
});
it('validateAppId should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
appIds: ['a', 'b'],
appidField: 'appId',
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
try {
await adapter.validateAppId(appIds, authData, providerOptions);
} catch (e) {
expect(e.message).toBe(
'OAuth2 token introspection endpoint URL is missing from configuration!'
);
}
});
it('validateAppId appidField optional', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
try {
await adapter.validateAppId(appIds, authData, providerOptions);
} catch (e) {
// Should not reach here
fail(e);
}
});
it('validateAppId should fail without appIds', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
appidField: 'appId',
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
try {
await adapter.validateAppId(appIds, authData, providerOptions);
} catch (e) {
expect(e.message).toBe(
'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).'
);
}
});
it('validateAppId should fail empty appIds', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
appidField: 'appId',
appIds: [],
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
try {
await adapter.validateAppId(appIds, authData, providerOptions);
} catch (e) {
expect(e.message).toBe(
'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).'
);
}
});
it('validateAppId invalid accessToken', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
appidField: 'appId',
appIds: ['a', 'b'],
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
spyOn(httpsRequest, 'request').and.callFake(() => {
return Promise.resolve({});
});
try {
await adapter.validateAppId(appIds, authData, providerOptions);
} catch (e) {
expect(e.message).toBe('OAuth2 access token is invalid for this user.');
}
});
it('validateAppId invalid accessToken appId', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
appidField: 'appId',
appIds: ['a', 'b'],
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
spyOn(httpsRequest, 'request').and.callFake(() => {
return Promise.resolve({ active: true });
});
try {
await adapter.validateAppId(appIds, authData, providerOptions);
} catch (e) {
expect(e.message).toBe(
"OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration."
);
}
});
it('validateAppId valid accessToken appId', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
appidField: 'appId',
appIds: ['a', 'b'],
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
spyOn(httpsRequest, 'request').and.callFake(() => {
return Promise.resolve({
active: true,
appId: 'a',
});
});
try {
await adapter.validateAppId(appIds, authData, providerOptions);
} catch (e) {
// Should not enter here
fail(e);
}
});
it('validateAppId valid accessToken appId array', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
appidField: 'appId',
appIds: ['a', 'b'],
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
spyOn(httpsRequest, 'request').and.callFake(() => {
return Promise.resolve({
active: true,
appId: ['a'],
});
});
try {
await adapter.validateAppId(appIds, authData, providerOptions);
} catch (e) {
// Should not enter here
fail(e);
}
});
it('validateAppId valid accessToken invalid appId', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
appidField: 'appId',
appIds: ['a', 'b'],
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
spyOn(httpsRequest, 'request').and.callFake(() => {
return Promise.resolve({
active: true,
appId: 'unknown',
});
});
try {
await adapter.validateAppId(appIds, authData, providerOptions);
} catch (e) {
expect(e.message).toBe(
"OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration."
);
}
});
it('validateAuthData should fail if OAuth2 tokenIntrospectionEndpointUrl is not configured properly', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
try {
await adapter.validateAuthData(authData, providerOptions);
} catch (e) {
expect(e.message).toBe(
'OAuth2 token introspection endpoint URL is missing from configuration!'
);
}
});
it('validateAuthData invalid accessToken', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
useridField: 'sub',
appidField: 'appId',
appIds: ['a', 'b'],
authorizationHeader: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=',
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
spyOn(httpsRequest, 'request').and.callFake(() => {
return Promise.resolve({});
});
try {
await adapter.validateAuthData(authData, providerOptions);
} catch (e) {
expect(e.message).toBe('OAuth2 access token is invalid for this user.');
}
expect(httpsRequest.request).toHaveBeenCalledWith(
{
hostname: 'example.com',
path: '/introspect',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': 15,
Authorization: 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=',
},
},
'token=sometoken'
);
});
it('validateAuthData valid accessToken', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
useridField: 'sub',
appidField: 'appId',
appIds: ['a', 'b'],
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
spyOn(httpsRequest, 'request').and.callFake(() => {
return Promise.resolve({
active: true,
sub: 'fakeid',
});
});
try {
await adapter.validateAuthData(authData, providerOptions);
} catch (e) {
// Should not enter here
fail(e);
}
expect(httpsRequest.request).toHaveBeenCalledWith(
{
hostname: 'example.com',
path: '/introspect',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': 15,
},
},
'token=sometoken'
);
});
it('validateAuthData valid accessToken without useridField', async () => {
const options = {
oauth2Authentication: {
oauth2: true,
tokenIntrospectionEndpointUrl: 'https://example.com/introspect',
appidField: 'appId',
appIds: ['a', 'b'],
},
};
const authData = {
id: 'fakeid',
access_token: 'sometoken',
};
const { adapter, providerOptions } = authenticationLoader.loadAuthAdapter(
'oauth2Authentication',
options
);
spyOn(httpsRequest, 'request').and.callFake(() => {
return Promise.resolve({
active: true,
sub: 'fakeid',
});
});
try {
await adapter.validateAuthData(authData, providerOptions);
} catch (e) {
// Should not enter here
fail(e);
}
});
});
describe('apple signin auth adapter', () => { describe('apple signin auth adapter', () => {
const apple = require('../lib/Adapters/Auth/apple'); const apple = require('../lib/Adapters/Auth/apple');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
@@ -1722,206 +1135,17 @@ describe('apple signin auth adapter', () => {
}); });
}); });
describe('Apple Game Center Auth adapter', () => {
const gcenter = require('../lib/Adapters/Auth/gcenter');
const fs = require('fs');
const testCert = fs.readFileSync(__dirname + '/support/cert/game_center.pem');
const testCert2 = fs.readFileSync(__dirname + '/support/cert/game_center.pem');
it('can load adapter', async () => {
const options = {
gcenter: {
rootCertificateUrl:
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
},
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'gcenter',
options
);
await adapter.validateAppId(
appIds,
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
providerOptions
);
});
it('validateAuthData should validate', async () => {
const options = {
gcenter: {
rootCertificateUrl:
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
},
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'gcenter',
options
);
await adapter.validateAppId(
appIds,
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
providerOptions
);
// real token is used
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer',
timestamp: 1565257031287,
signature:
'uqLBTr9Uex8zCpc1UQ1MIDMitb+HUat2Mah4Kw6AVLSGe0gGNJXlih2i5X+0ZwVY0S9zY2NHWi2gFjmhjt/4kxWGMkupqXX5H/qhE2m7hzox6lZJpH98ZEUbouWRfZX2ZhUlCkAX09oRNi7fI7mWL1/o88MaI/y6k6tLr14JTzmlxgdyhw+QRLxRPA6NuvUlRSJpyJ4aGtNH5/wHdKQWL8nUnFYiYmaY8R7IjzNxPfy8UJTUWmeZvMSgND4u8EjADPsz7ZtZyWAPi8kYcAb6M8k0jwLD3vrYCB8XXyO2RQb/FY2TM4zJuI7PzLlvvgOJXbbfVtHx7Evnm5NYoyzgzw==',
salt: 'DzqqrQ==',
bundleId: 'cloud.xtralife.gamecenterauth',
};
gcenter.cache['https://static.gc.apple.com/public-key/gc-prod-4.cer'] = testCert;
await gcenter.validateAuthData(authData);
});
it('validateAuthData invalid signature id', async () => {
gcenter.cache['https://static.gc.apple.com/public-key/gc-prod-4.cer'] = testCert;
gcenter.cache['https://static.gc.apple.com/public-key/gc-prod-6.cer'] = testCert2;
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'gcenter',
{}
);
await adapter.validateAppId(
appIds,
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
providerOptions
);
const authData = {
id: 'G:1965586982',
publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-6.cer',
timestamp: 1565257031287,
signature: '1234',
salt: 'DzqqrQ==',
bundleId: 'com.example.com',
};
await expectAsync(gcenter.validateAuthData(authData)).toBeRejectedWith(
new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Apple Game Center - invalid signature')
);
});
it('validateAuthData invalid public key http url', async () => {
const options = {
gcenter: {
rootCertificateUrl:
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
},
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'gcenter',
options
);
await adapter.validateAppId(
appIds,
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
providerOptions
);
const publicKeyUrls = [
'example.com',
'http://static.gc.apple.com/public-key/gc-prod-4.cer',
'https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg',
'https://example.com/ \\.apple.com/public_key.cer',
'https://example.com/ &.apple.com/public_key.cer',
];
await Promise.all(
publicKeyUrls.map(publicKeyUrl =>
expectAsync(
gcenter.validateAuthData({
id: 'G:1965586982',
timestamp: 1565257031287,
publicKeyUrl,
signature: '1234',
salt: 'DzqqrQ==',
bundleId: 'com.example.com',
})
).toBeRejectedWith(
new Parse.Error(
Parse.Error.SCRIPT_FAILED,
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
)
)
)
);
});
it('should not validate Symantec Cert', async () => {
const options = {
gcenter: {
rootCertificateUrl:
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem',
},
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'gcenter',
options
);
await adapter.validateAppId(
appIds,
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
providerOptions
);
expect(() =>
gcenter.verifyPublicKeyIssuer(
testCert,
'https://static.gc.apple.com/public-key/gc-prod-4.cer'
)
);
});
it('adapter should load default cert', async () => {
const options = {
gcenter: {},
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'gcenter',
options
);
await adapter.validateAppId(
appIds,
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
providerOptions
);
const previous = new Date();
await adapter.validateAppId(
appIds,
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
providerOptions
);
const duration = new Date().getTime() - previous.getTime();
expect(duration <= 1).toBe(true);
});
it('adapter should throw', async () => {
const options = {
gcenter: {
rootCertificateUrl: 'https://example.com',
},
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'gcenter',
options
);
await expectAsync(
adapter.validateAppId(
appIds,
{ publicKeyUrl: 'https://static.gc.apple.com/public-key/gc-prod-4.cer' },
providerOptions
)
).toBeRejectedWith(
new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.'
)
);
});
});
describe('phant auth adapter', () => { describe('phant auth adapter', () => {
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest'); const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
it('validateAuthData should throw for invalid auth', async () => { it('validateAuthData should throw for invalid auth', async () => {
await reconfigureServer({
auth: {
phantauth: {
enableInsecureAuth: true,
}
}
})
const authData = { const authData = {
id: 'fakeid', id: 'fakeid',
access_token: 'sometoken', access_token: 'sometoken',
@@ -1938,34 +1162,6 @@ describe('phant auth adapter', () => {
}); });
}); });
describe('microsoft graph auth adapter', () => {
const microsoft = require('../lib/Adapters/Auth/microsoft');
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
it('should use access_token for validation is passed and responds with id and mail', async () => {
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ id: 'userId', mail: 'userMail' });
});
await microsoft.validateAuthData({
id: 'userId',
access_token: 'the_token',
});
});
it('should fail to validate Microsoft Graph auth with bad token', done => {
const authData = {
id: 'fake-id',
mail: 'fake@mail.com',
access_token: 'very.long.bad.token',
};
microsoft.validateAuthData(authData).then(done.fail, err => {
expect(err.code).toBe(Parse.Error.OBJECT_NOT_FOUND);
expect(err.message).toBe('Microsoft Graph auth is invalid for this user.');
done();
});
});
});
describe('facebook limited auth adapter', () => { describe('facebook limited auth adapter', () => {
const facebook = require('../lib/Adapters/Auth/facebook'); const facebook = require('../lib/Adapters/Auth/facebook');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');

View File

@@ -355,16 +355,16 @@ describe('Auth Adapter features', () => {
const authData = user.get('authData').modernAdapter3; const authData = user.get('authData').modernAdapter3;
expect(authData).toEqual({ foo: 'bar' }); expect(authData).toEqual({ foo: 'bar' });
for (const call of afterSpy.calls.all()) { for (const call of afterSpy.calls.all()) {
const args = call.args[0]; const args = call.args[2];
if (args.user) { if (args.user) {
user._objCount = args.user._objCount; user._objCount = args.user._objCount;
break; break;
} }
} }
expect(afterSpy).toHaveBeenCalledWith( expect(afterSpy).toHaveBeenCalledWith(
{ ip: '127.0.0.1', user, master: false },
{ id: 'modernAdapter3Data' }, { id: 'modernAdapter3Data' },
undefined undefined,
{ ip: '127.0.0.1', user, master: false },
); );
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
}); });

View File

@@ -32,6 +32,7 @@ describe('Security Check Groups', () => {
config.masterKey = 'aMoreSecur3Passwor7!'; config.masterKey = 'aMoreSecur3Passwor7!';
config.security.enableCheckLog = false; config.security.enableCheckLog = false;
config.allowClientClassCreation = false; config.allowClientClassCreation = false;
config.enableInsecureAuthAdapters = false;
await reconfigureServer(config); await reconfigureServer(config);
const group = new CheckGroupServerConfig(); const group = new CheckGroupServerConfig();
@@ -39,6 +40,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[0].checkState()).toBe(CheckState.success); expect(group.checks()[0].checkState()).toBe(CheckState.success);
expect(group.checks()[1].checkState()).toBe(CheckState.success); expect(group.checks()[1].checkState()).toBe(CheckState.success);
expect(group.checks()[2].checkState()).toBe(CheckState.success); expect(group.checks()[2].checkState()).toBe(CheckState.success);
expect(group.checks()[4].checkState()).toBe(CheckState.success);
}); });
it('checks fail correctly', async () => { it('checks fail correctly', async () => {
@@ -52,6 +54,7 @@ describe('Security Check Groups', () => {
expect(group.checks()[0].checkState()).toBe(CheckState.fail); expect(group.checks()[0].checkState()).toBe(CheckState.fail);
expect(group.checks()[1].checkState()).toBe(CheckState.fail); expect(group.checks()[1].checkState()).toBe(CheckState.fail);
expect(group.checks()[2].checkState()).toBe(CheckState.fail); expect(group.checks()[2].checkState()).toBe(CheckState.fail);
expect(group.checks()[4].checkState()).toBe(CheckState.fail);
}); });
}); });

View File

@@ -1,97 +0,0 @@
const twitter = require('../lib/Adapters/Auth/twitter');
describe('Twitter Auth', () => {
it('should use the proper configuration', () => {
// Multiple options, consumer_key found
expect(
twitter.handleMultipleConfigurations(
{
consumer_key: 'hello',
},
[
{
consumer_key: 'hello',
},
{
consumer_key: 'world',
},
]
).consumer_key
).toEqual('hello');
// Multiple options, consumer_key not found
expect(function () {
twitter.handleMultipleConfigurations(
{
consumer_key: 'some',
},
[
{
consumer_key: 'hello',
},
{
consumer_key: 'world',
},
]
);
}).toThrow();
// Multiple options, consumer_key not found
expect(function () {
twitter.handleMultipleConfigurations(
{
auth_token: 'token',
},
[
{
consumer_key: 'hello',
},
{
consumer_key: 'world',
},
]
);
}).toThrow();
// Single configuration and consumer_key set
expect(
twitter.handleMultipleConfigurations(
{
consumer_key: 'hello',
},
{
consumer_key: 'hello',
}
).consumer_key
).toEqual('hello');
// General case, only 1 config, no consumer_key set
expect(
twitter.handleMultipleConfigurations(
{
auth_token: 'token',
},
{
consumer_key: 'hello',
}
).consumer_key
).toEqual('hello');
});
it('Should fail with missing options', done => {
try {
twitter.validateAuthData(
{
consumer_key: 'key',
consumer_secret: 'secret',
auth_token: 'token',
auth_token_secret: 'secret',
},
undefined
);
} catch (error) {
jequal(error.message, 'Twitter auth configuration missing');
done();
}
});
});

View File

@@ -9,6 +9,7 @@ module.exports = [
globals: { globals: {
...globals.node, ...globals.node,
...globals.jasmine, ...globals.jasmine,
mockFetch: "readonly",
Parse: "readonly", Parse: "readonly",
reconfigureServer: "readonly", reconfigureServer: "readonly",
createTestUser: "readonly", createTestUser: "readonly",

View File

@@ -414,6 +414,25 @@ function mockShortLivedAuth() {
return auth; return auth;
} }
function mockFetch(mockResponses) {
global.fetch = jasmine.createSpy('fetch').and.callFake((url, options = { }) => {
options.method ||= 'GET';
const mockResponse = mockResponses.find(
(mock) => mock.url === url && mock.method === options.method
);
if (mockResponse) {
return Promise.resolve(mockResponse.response);
}
return Promise.resolve({
ok: false,
statusText: 'Unknown URL or method',
});
});
}
// This is polluting, but, it makes it way easier to directly port old tests. // This is polluting, but, it makes it way easier to directly port old tests.
global.Parse = Parse; global.Parse = Parse;
global.TestObject = TestObject; global.TestObject = TestObject;
@@ -429,6 +448,7 @@ global.arrayContains = arrayContains;
global.jequal = jequal; global.jequal = jequal;
global.range = range; global.range = range;
global.reconfigureServer = reconfigureServer; global.reconfigureServer = reconfigureServer;
global.mockFetch = mockFetch;
global.defaultConfiguration = defaultConfiguration; global.defaultConfiguration = defaultConfiguration;
global.mockCustomAuthenticator = mockCustomAuthenticator; global.mockCustomAuthenticator = mockCustomAuthenticator;
global.mockFacebookAuthenticator = mockFacebookAuthenticator; global.mockFacebookAuthenticator = mockFacebookAuthenticator;

View File

@@ -0,0 +1,38 @@
-----BEGIN CERTIFICATE-----
MIIGsDCCBJigAwIBAgIQCK1AsmDSnEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBi
MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3Qg
RzQwHhcNMjEwNDI5MDAwMDAwWhcNMzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJV
UzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRy
dXN0ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIIC
IjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1
M4zrPYGXcMW7xIUmMJ+kjmjYXPXrNCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZ
wZHMgQM+TXAkZLON4gh9NH1MgFcSa0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI
8IrgnQnAZaf6mIBJNYc9URnokCF4RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGi
TUyCEUhSaN4QvRRXXegYE2XFf7JPhSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLm
ysL0p6MDDnSlrzm2q2AS4+jWufcx4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3S
vUQakhCBj7A7CdfHmzJawv9qYFSLScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tv
k2E0XLyTRSiDNipmKF+wc86LJiUGsoPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+
960IHnWmZcy740hQ83eRGv7bUKJGyGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3s
MJN2FKZbS110YU0/EpF23r9Yy3IQKUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FK
PkBHX8mBUHOFECMhWWCKZFTBzCEa6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1H
s/q27IwyCQLMbDwMVhECAwEAAaOCAVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAw
HQYDVR0OBBYEFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LS
cV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEF
BQcDAzB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRp
Z2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQu
Y29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYy
aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5j
cmwwHAYDVR0gBBUwEzAHBgVngQwBAzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQAD
ggIBADojRD2NCHbuj7w6mdNW4AIapfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L
/Z6jfCbVN7w6XUhtldU/SfQnuxaBRVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHV
UHmImoqKwba9oUgYftzYgBoRGRjNYZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rd
KOtfJqGVWEjVGv7XJz/9kNF2ht0csGBc8w2o7uCJob054ThO2m67Np375SFTWsPK
6Wrxoj7bQ7gzyE84FJKZ9d3OVG3ZXQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43N
b3Y3LIU/Gs4m6Ri+kAewQ3+ViCCCcPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4Z
XDlx4b6cpwoG1iZnt5LmTl/eeqxJzy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvm
oLr9Oj9FpsToFpFSi0HASIRLlk2rREDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8
y4+ICw2/O/TOHnuO77Xry7fwdxPm5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMM
B0ug0wcCampAMEhLNKhRILutG4UI4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+F
SCH5Vzu0nAPthkX0tGFuv2jiJmCG6sivqf6UHedjGzqGVnhO
-----END CERTIFICATE-----

Binary file not shown.

View File

@@ -1,6 +1,6 @@
{ {
"spec_dir": "spec", "spec_dir": "spec",
"spec_files": ["*spec.js"], "spec_files": ["**/*.[sS]pec.js"],
"helpers": ["helper.js"], "helpers": ["helper.js"],
"random": true "random": true
} }

View File

@@ -40,11 +40,11 @@ export class AuthAdapter {
* Legacy usage, if provided it will be triggered when authData related to this provider is touched (signup/update/login) * Legacy usage, if provided it will be triggered when authData related to this provider is touched (signup/update/login)
* otherwise you should implement validateSetup, validateLogin and validateUpdate * otherwise you should implement validateSetup, validateLogin and validateUpdate
* @param {Object} authData The client provided authData * @param {Object} authData The client provided authData
* @param {Parse.Cloud.TriggerRequest} request
* @param {Object} options additional adapter options * @param {Object} options additional adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {Promise<ParseAuthResponse|void|undefined>} * @returns {Promise<ParseAuthResponse|void|undefined>}
*/ */
validateAuthData(authData, request, options) { validateAuthData(authData, options, request) {
return Promise.resolve({}); return Promise.resolve({});
} }
@@ -52,11 +52,11 @@ export class AuthAdapter {
* Triggered when user provide for the first time this auth provider * Triggered when user provide for the first time this auth provider
* could be a register or the user adding a new auth service * could be a register or the user adding a new auth service
* @param {Object} authData The client provided authData * @param {Object} authData The client provided authData
* @param {Parse.Cloud.TriggerRequest} request
* @param {Object} options additional adapter options * @param {Object} options additional adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {Promise<ParseAuthResponse|void|undefined>} * @returns {Promise<ParseAuthResponse|void|undefined>}
*/ */
validateSetUp(authData, req, options) { validateSetUp(authData, options, req) {
return Promise.resolve({}); return Promise.resolve({});
} }
@@ -64,11 +64,11 @@ export class AuthAdapter {
* Triggered when user provide authData related to this provider * Triggered when user provide authData related to this provider
* The user is not logged in and has already set this provider before * The user is not logged in and has already set this provider before
* @param {Object} authData The client provided authData * @param {Object} authData The client provided authData
* @param {Parse.Cloud.TriggerRequest} request
* @param {Object} options additional adapter options * @param {Object} options additional adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {Promise<ParseAuthResponse|void|undefined>} * @returns {Promise<ParseAuthResponse|void|undefined>}
*/ */
validateLogin(authData, req, options) { validateLogin(authData, options, req) {
return Promise.resolve({}); return Promise.resolve({});
} }
@@ -80,10 +80,18 @@ export class AuthAdapter {
* @param {Parse.Cloud.TriggerRequest} request * @param {Parse.Cloud.TriggerRequest} request
* @returns {Promise<ParseAuthResponse|void|undefined>} * @returns {Promise<ParseAuthResponse|void|undefined>}
*/ */
validateUpdate(authData, req, options) { validateUpdate(authData, options, req) {
return Promise.resolve({}); return Promise.resolve({});
} }
/**
* Triggered when user is looked up by authData with this provider. Override the `id` field if needed.
* @param {Object} authData The client provided authData
*/
beforeFind(authData) {
}
/** /**
* Triggered in pre authentication process if needed (like webauthn, SMS OTP) * Triggered in pre authentication process if needed (like webauthn, SMS OTP)
* @param {Object} challengeData Data provided by the client * @param {Object} challengeData Data provided by the client
@@ -100,9 +108,10 @@ export class AuthAdapter {
* Triggered when auth data is fetched * Triggered when auth data is fetched
* @param {Object} authData authData * @param {Object} authData authData
* @param {Object} options additional adapter options * @param {Object} options additional adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {Promise<Object>} Any overrides required to authData * @returns {Promise<Object>} Any overrides required to authData
*/ */
afterFind(authData, options) { afterFind(authData, options, request) {
return Promise.resolve({}); return Promise.resolve({});
} }

View File

@@ -0,0 +1,112 @@
// abstract class for auth code adapters
import AuthAdapter from './AuthAdapter';
export default class BaseAuthCodeAdapter extends AuthAdapter {
constructor(adapterName) {
super();
this.adapterName = adapterName;
}
validateOptions(options) {
if (!options) {
throw new Error(`${this.adapterName} options are required.`);
}
this.enableInsecureAuth = options.enableInsecureAuth;
if (this.enableInsecureAuth) {
return;
}
this.clientId = options.clientId;
this.clientSecret = options.clientSecret;
if (!this.clientId) {
throw new Error(`${this.adapterName} clientId is required.`);
}
if (!this.clientSecret) {
throw new Error(`${this.adapterName} clientSecret is required.`);
}
}
async beforeFind(authData) {
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.`);
}
const user = await this.getUserFromAccessToken(authData.access_token, authData);
if (user.id !== authData.id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`);
}
return;
}
if (!authData?.code) {
throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `${this.adapterName} code is required.`);
}
const access_token = await this.getAccessTokenFromCode(authData);
const user = await this.getUserFromAccessToken(access_token, authData);
if (authData.id && user.id !== authData.id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`);
}
authData.access_token = access_token;
authData.id = user.id;
delete authData.code;
delete authData.redirect_uri;
}
async getUserFromAccessToken() {
// abstract method
throw new Error('getUserFromAccessToken is not implemented');
}
async getAccessTokenFromCode() {
// abstract method
throw new Error('getAccessTokenFromCode is not implemented');
}
validateLogin(authData) {
// User validation is already done in beforeFind
return {
id: authData.id,
}
}
validateSetUp(authData) {
// User validation is already done in beforeFind
return {
id: authData.id,
}
}
afterFind(authData) {
return {
id: authData.id,
}
}
validateUpdate(authData) {
// User validation is already done in beforeFind
return {
id: authData.id,
}
}
parseResponseData(data) {
const startPos = data.indexOf('(');
const endPos = data.indexOf(')');
if (startPos === -1 || endPos === -1) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${this.adapterName} auth is invalid for this user.`);
}
const jsonData = data.substring(startPos + 1, endPos);
return JSON.parse(jsonData);
}
}

View File

@@ -1,3 +1,47 @@
/**
* Parse Server authentication adapter for Apple.
*
* @class AppleAdapter
* @param {Object} options - Configuration options for the adapter.
* @param {string} options.clientId - Your Apple App ID.
*
* @param {Object} authData - The authentication data provided by the client.
* @param {string} authData.id - The user ID obtained from Apple.
* @param {string} authData.token - The token obtained from Apple.
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Apple authentication, use the following structure:
* ```json
* {
* "auth": {
* "apple": {
* "clientId": "12345"
* }
* }
* }
* ```
*
* ## Expected `authData` from the Client
* The adapter expects the client to provide the following `authData` payload:
* - `authData.id` (**string**, required): The user ID obtained from Apple.
* - `authData.token` (**string**, required): The token obtained from Apple.
*
* Parse Server stores the required authentication data in the database.
*
* ### Example AuthData from Apple
* ```json
* {
* "apple": {
* "id": "1234567",
* "token": "xxxxx.yyyyy.zzzzz"
* }
* }
* ```
*
* @see {@link https://developer.apple.com/documentation/signinwithapplerestapi Sign in with Apple REST API Documentation}
*/
// Apple SignIn Auth // Apple SignIn Auth
// https://developer.apple.com/documentation/signinwithapplerestapi // https://developer.apple.com/documentation/signinwithapplerestapi

View File

@@ -1,3 +1,63 @@
/**
* Parse Server authentication adapter for Facebook.
*
* @class FacebookAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.appSecret - Your Facebook App Secret. Required for secure authentication.
* @param {string[]} options.appIds - An array of Facebook App IDs. Required for validating the app.
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Facebook authentication, use the following structure:
* ```json
* {
* "auth": {
* "facebook": {
* "appSecret": "your-app-secret",
* "appIds": ["your-app-id"]
* }
* }
* }
* ```
*
* The adapter supports the following authentication methods:
* - **Standard Login**: Requires `id` and `access_token`.
* - **Limited Login**: Requires `id` and `token`.
*
* ## Auth Payloads
* ### Standard Login Payload
* ```json
* {
* "facebook": {
* "id": "1234567",
* "access_token": "abc123def456ghi789"
* }
* }
* ```
*
* ### Limited Login Payload
* ```json
* {
* "facebook": {
* "id": "1234567",
* "token": "xxxxx.yyyyy.zzzzz"
* }
* }
* ```
*
* ## Notes
* - **Standard Login**: Use `id` and `access_token` for full functionality.
* - **Limited Login**: Use `id` and `token` (JWT) when tracking is opted out (e.g., via Apple's App Tracking Transparency).
* - Supported Parse Server versions:
* - `>= 6.5.6 < 7`
* - `>= 7.0.1`
*
* Secure authentication is recommended to ensure proper data protection and compliance with Facebook's guidelines.
*
* @see {@link https://developers.facebook.com/docs/facebook-login/limited-login/ Facebook Limited Login}
* @see {@link https://developers.facebook.com/docs/facebook-login/facebook-login-for-business/ Facebook Login for Business}
*/
// Helper functions for accessing the Facebook Graph API. // Helper functions for accessing the Facebook Graph API.
const Parse = require('parse/node').Parse; const Parse = require('parse/node').Parse;
const crypto = require('crypto'); const crypto = require('crypto');

View File

@@ -1,195 +1,239 @@
/* Apple Game Center Auth /**
https://developer.apple.com/documentation/gamekit/gklocalplayer/1515407-generateidentityverificationsign#discussion * Parse Server authentication adapter for Apple Game Center.
*
* @class AppleGameCenterAdapter
* @param {Object} options - Configuration options for the adapter.
* @param {string} options.bundleId - Your Apple Game Center bundle ID. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @param {Object} authData - The authentication data provided by the client.
* @param {string} authData.id - The user ID obtained from Apple Game Center.
* @param {string} authData.publicKeyUrl - The public key URL obtained from Apple Game Center.
* @param {string} authData.timestamp - The timestamp obtained from Apple Game Center.
* @param {string} authData.signature - The signature obtained from Apple Game Center.
* @param {string} authData.salt - The salt obtained from Apple Game Center.
* @param {string} [authData.bundleId] - **[DEPRECATED]** The bundle ID obtained from Apple Game Center (required for insecure authentication).
*
* @description
* ## Parse Server Configuration
* The following `authData` fields are required:
* `id`, `publicKeyUrl`, `timestamp`, `signature`, and `salt`. These fields are validated against the configured `bundleId` for additional security.
*
* To configure Parse Server for Apple Game Center authentication, use the following structure:
* ```json
* {
* "auth": {
* "gcenter": {
* "bundleId": "com.valid.app"
* }
* }
* ```
*
* ## Insecure Authentication (Not Recommended)
* The following `authData` fields are required for insecure authentication:
* `id`, `publicKeyUrl`, `timestamp`, `signature`, `salt`, and `bundleId` (**[DEPRECATED]**). This flow is insecure and poses potential security risks.
*
* To configure Parse Server for insecure authentication, use the following structure:
* ```json
* {
* "auth": {
* "gcenter": {
* "enableInsecureAuth": true
* }
* }
* ```
*
* ### Deprecation Notice
* The `enableInsecureAuth` option and `authData.bundleId` parameter are deprecated and may be removed in future releases. Use secure authentication with the `bundleId` configured in the `options` object instead.
*
*
* @example <caption>Secure Authentication Example</caption>
* // Example authData for secure authentication:
* const authData = {
* gcenter: {
* id: "1234567",
* publicKeyUrl: "https://valid.apple.com/public/timeout.cer",
* timestamp: 1460981421303,
* salt: "saltST==",
* signature: "PoDwf39DCN464B49jJCU0d9Y0J"
* }
* };
*
* @example <caption>Insecure Authentication Example (Not Recommended)</caption>
* // Example authData for insecure authentication:
* const authData = {
* gcenter: {
* id: "1234567",
* publicKeyUrl: "https://valid.apple.com/public/timeout.cer",
* timestamp: 1460981421303,
* salt: "saltST==",
* signature: "PoDwf39DCN464B49jJCU0d9Y0J",
* bundleId: "com.valid.app" // Deprecated.
* }
* };
*
* @see {@link https://developer.apple.com/documentation/gamekit/gklocalplayer/3516283-fetchitems Apple Game Center Documentation}
*/
/* global BigInt */
const authData = { import crypto from 'crypto';
publicKeyUrl: 'https://valid.apple.com/public/timeout.cer', import { asn1, pki } from 'node-forge';
timestamp: 1460981421303, import AuthAdapter from './AuthAdapter';
signature: 'PoDwf39DCN464B49jJCU0d9Y0J', class GameCenterAuth extends AuthAdapter {
salt: 'saltST==', constructor() {
bundleId: 'com.valid.app' super();
id: 'playerId', this.ca = { cert: null, url: null };
}; this.cache = {};
*/ this.bundleId = '';
}
const { Parse } = require('parse/node'); validateOptions(options) {
const crypto = require('crypto'); if (!options) {
const https = require('https'); throw new Error('Game center auth options are required.');
const { pki } = require('node-forge'); }
const ca = { cert: null, url: null };
const cache = {}; // (publicKey -> cert) cache
function verifyPublicKeyUrl(publicKeyUrl) { if (!this.loadingPromise) {
try { this.loadingPromise = this.loadCertificate(options);
}
this.enableInsecureAuth = options.enableInsecureAuth;
this.bundleId = options.bundleId;
if (!this.enableInsecureAuth && !this.bundleId) {
throw new Error('bundleId is required for secure auth.');
}
}
async loadCertificate(options) {
const rootCertificateUrl =
options.rootCertificateUrl ||
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem';
if (this.ca.url === rootCertificateUrl) {
return rootCertificateUrl;
}
const { certificate, headers } = await this.fetchCertificate(rootCertificateUrl);
if (
headers.get('content-type') !== 'application/x-pem-file' ||
!headers.get('content-length') ||
parseInt(headers.get('content-length'), 10) > 10000
) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid rootCertificateURL.');
}
this.ca.cert = pki.certificateFromPem(certificate);
this.ca.url = rootCertificateUrl;
return rootCertificateUrl;
}
verifyPublicKeyUrl(publicKeyUrl) {
const regex = /^https:\/\/(?:[-_A-Za-z0-9]+\.){0,}apple\.com\/.*\.cer$/; const regex = /^https:\/\/(?:[-_A-Za-z0-9]+\.){0,}apple\.com\/.*\.cer$/;
return regex.test(publicKeyUrl); return regex.test(publicKeyUrl);
} catch (error) {
return false;
} }
}
function convertX509CertToPEM(X509Cert) { async fetchCertificate(url) {
const pemPreFix = '-----BEGIN CERTIFICATE-----\n'; const response = await fetch(url);
const pemPostFix = '-----END CERTIFICATE-----'; if (!response.ok) {
throw new Error(`Failed to fetch certificate: ${url}`);
const base64 = X509Cert;
const certBody = base64.match(new RegExp('.{0,64}', 'g')).join('\n');
return pemPreFix + certBody + pemPostFix;
}
async function getAppleCertificate(publicKeyUrl) {
if (!verifyPublicKeyUrl(publicKeyUrl)) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
);
}
if (cache[publicKeyUrl]) {
return cache[publicKeyUrl];
}
const url = new URL(publicKeyUrl);
const headOptions = {
hostname: url.hostname,
path: url.pathname,
method: 'HEAD',
};
const cert_headers = await new Promise((resolve, reject) =>
https.get(headOptions, res => resolve(res.headers)).on('error', reject)
);
const validContentTypes = ['application/x-x509-ca-cert', 'application/pkix-cert'];
if (
!validContentTypes.includes(cert_headers['content-type']) ||
cert_headers['content-length'] == null ||
cert_headers['content-length'] > 10000
) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}`
);
}
const { certificate, headers } = await getCertificate(publicKeyUrl);
if (headers['cache-control']) {
const expire = headers['cache-control'].match(/max-age=([0-9]+)/);
if (expire) {
cache[publicKeyUrl] = certificate;
// we'll expire the cache entry later, as per max-age
setTimeout(() => {
delete cache[publicKeyUrl];
}, parseInt(expire[1], 10) * 1000);
} }
const contentType = response.headers.get('content-type');
const isPem = contentType?.includes('application/x-pem-file');
if (isPem) {
const certificate = await response.text();
return { certificate, headers: response.headers };
}
const data = await response.arrayBuffer();
const binaryData = Buffer.from(data);
const asn1Cert = asn1.fromDer(binaryData.toString('binary'));
const forgeCert = pki.certificateFromAsn1(asn1Cert);
const certificate = pki.certificateToPem(forgeCert);
return { certificate, headers: response.headers };
} }
return verifyPublicKeyIssuer(certificate, publicKeyUrl);
}
function getCertificate(url, buffer) { async getAppleCertificate(publicKeyUrl) {
return new Promise((resolve, reject) => { if (!this.verifyPublicKeyUrl(publicKeyUrl)) {
https throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `Invalid publicKeyUrl: ${publicKeyUrl}`);
.get(url, res => { }
const data = [];
res.on('data', chunk => {
data.push(chunk);
});
res.on('end', () => {
if (buffer) {
resolve({ certificate: Buffer.concat(data), headers: res.headers });
return;
}
let cert = '';
for (const chunk of data) {
cert += chunk.toString('base64');
}
const certificate = convertX509CertToPEM(cert);
resolve({ certificate, headers: res.headers });
});
})
.on('error', reject);
});
}
function convertTimestampToBigEndian(timestamp) { if (this.cache[publicKeyUrl]) {
const buffer = Buffer.alloc(8); return this.cache[publicKeyUrl];
}
const high = ~~(timestamp / 0xffffffff); const { certificate, headers } = await this.fetchCertificate(publicKeyUrl);
const low = timestamp % (0xffffffff + 0x1); const cacheControl = headers.get('cache-control');
const expire = cacheControl?.match(/max-age=([0-9]+)/);
buffer.writeUInt32BE(parseInt(high, 10), 0); this.verifyPublicKeyIssuer(certificate, publicKeyUrl);
buffer.writeUInt32BE(parseInt(low, 10), 4);
return buffer; if (expire) {
} this.cache[publicKeyUrl] = certificate;
setTimeout(() => delete this.cache[publicKeyUrl], parseInt(expire[1], 10) * 1000);
}
function verifySignature(publicKey, authData) { return certificate;
const verifier = crypto.createVerify('sha256');
verifier.update(authData.playerId, 'utf8');
verifier.update(authData.bundleId, 'utf8');
verifier.update(convertTimestampToBigEndian(authData.timestamp));
verifier.update(authData.salt, 'base64');
if (!verifier.verify(publicKey, authData.signature, 'base64')) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Apple Game Center - invalid signature');
} }
}
function verifyPublicKeyIssuer(cert, publicKeyUrl) { verifyPublicKeyIssuer(cert, publicKeyUrl) {
const publicKeyCert = pki.certificateFromPem(cert); const publicKeyCert = pki.certificateFromPem(cert);
if (!ca.cert) {
throw new Parse.Error( if (!this.ca.cert) {
Parse.Error.OBJECT_NOT_FOUND,
'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.'
);
}
try {
if (!ca.cert.verify(publicKeyCert)) {
throw new Parse.Error( throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND, Parse.Error.OBJECT_NOT_FOUND,
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}` 'Root certificate is invalid or missing.'
); );
} }
} catch (e) {
throw new Parse.Error( if (!this.ca.cert.verify(publicKeyCert)) {
Parse.Error.OBJECT_NOT_FOUND, throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `Invalid publicKeyUrl: ${publicKeyUrl}`);
`Apple Game Center - invalid publicKeyUrl: ${publicKeyUrl}` }
); }
verifySignature(publicKey, authData) {
const bundleId = this.bundleId || (this.enableInsecureAuth && authData.bundleId);
const verifier = crypto.createVerify('sha256');
verifier.update(Buffer.from(authData.id, 'utf8'));
verifier.update(Buffer.from(bundleId, 'utf8'));
verifier.update(this.convertTimestampToBigEndian(authData.timestamp));
verifier.update(Buffer.from(authData.salt, 'base64'));
if (!verifier.verify(publicKey, authData.signature, 'base64')) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid signature.');
}
}
async validateAuthData(authData) {
const requiredKeys = ['id', 'publicKeyUrl', 'timestamp', 'signature', 'salt'];
if (this.enableInsecureAuth) {
requiredKeys.push('bundleId');
}
for (const key of requiredKeys) {
if (!authData[key]) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `AuthData ${key} is missing.`);
}
}
await this.loadingPromise;
const publicKey = await this.getAppleCertificate(authData.publicKeyUrl);
this.verifySignature(publicKey, authData);
}
convertTimestampToBigEndian(timestamp) {
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64BE(BigInt(timestamp));
return buffer;
} }
return cert;
} }
// Returns a promise that fulfills if this user id is valid. export default new GameCenterAuth();
async function validateAuthData(authData) {
if (!authData.id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Apple Game Center - authData id missing');
}
authData.playerId = authData.id;
const publicKey = await getAppleCertificate(authData.publicKeyUrl);
return verifySignature(publicKey, authData);
}
// Returns a promise that fulfills if this app id is valid.
async function validateAppId(appIds, authData, options = {}) {
if (!options.rootCertificateUrl) {
options.rootCertificateUrl =
'https://cacerts.digicert.com/DigiCertTrustedG4CodeSigningRSA4096SHA3842021CA1.crt.pem';
}
if (ca.url === options.rootCertificateUrl) {
return;
}
const { certificate, headers } = await getCertificate(options.rootCertificateUrl, true);
if (
headers['content-type'] !== 'application/x-pem-file' ||
headers['content-length'] == null ||
headers['content-length'] > 10000
) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Apple Game Center auth adapter parameter `rootCertificateURL` is invalid.'
);
}
ca.cert = pki.certificateFromPem(certificate);
ca.url = options.rootCertificateUrl;
}
module.exports = {
validateAppId,
validateAuthData,
cache,
};

View File

@@ -1,35 +1,127 @@
// Helper functions for accessing the github API. /**
var Parse = require('parse/node').Parse; * Parse Server authentication adapter for GitHub.
const httpsRequest = require('./httpsRequest'); * @class GitHubAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - The GitHub App Client ID. Required for secure authentication.
* @param {string} options.clientSecret - The GitHub App Client Secret. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @param {Object} authData - The authentication data provided by the client.
* @param {string} authData.code - The authorization code from GitHub. Required for secure authentication.
* @param {string} [authData.id] - **[DEPRECATED]** The GitHub user ID (required for insecure authentication).
* @param {string} [authData.access_token] - **[DEPRECATED]** The GitHub access token (required for insecure authentication).
*
* @description
* ## Parse Server Configuration
* * To configure Parse Server for GitHub authentication, use the following structure:
* ```json
* {
* "auth": {
* "github": {
* "clientId": "12345",
* "clientSecret": "abcde"
* }
* }
* ```
*
* The GitHub adapter exchanges the `authData.code` provided by the client for an access token using GitHub's OAuth API. The following `authData` field is required:
* - `code`
*
* ## Insecure Authentication (Not Recommended)
* Insecure authentication uses the `authData.id` and `authData.access_token` provided by the client. This flow is insecure, deprecated, and poses potential security risks. The following `authData` fields are required:
* - `id` (**[DEPRECATED]**): The GitHub user ID.
* - `access_token` (**[DEPRECATED]**): The GitHub access token.
* To configure Parse Server for insecure authentication, use the following structure:
* ```json
* {
* "auth": {
* "github": {
* "enableInsecureAuth": true
* }
* }
* ```
*
* ### Deprecation Notice
* The `enableInsecureAuth` option and insecure `authData` fields (`id`, `access_token`) are deprecated and will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`.
*
* @example <caption>Secure Authentication Example</caption>
* // Example authData for secure authentication:
* const authData = {
* github: {
* code: "abc123def456ghi789"
* }
* };
*
* @example <caption>Insecure Authentication Example (Not Recommended)</caption>
* // Example authData for insecure authentication:
* const authData = {
* github: {
* id: "1234567",
* access_token: "abc123def456ghi789" // Deprecated.
* }
* };
*
* @note `enableInsecureAuth` will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`.
* @note Secure authentication exchanges the `code` provided by the client for an access token using GitHub's OAuth API.
*
* @see {@link https://docs.github.com/en/developers/apps/authorizing-oauth-apps GitHub OAuth Documentation}
*/
// Returns a promise that fulfills iff this user id is valid. import BaseCodeAuthAdapter from './BaseCodeAuthAdapter';
function validateAuthData(authData) { class GitHubAdapter extends BaseCodeAuthAdapter {
return request('user', authData.access_token).then(data => { constructor() {
if (data && data.id == authData.id) { super('GitHub');
return; }
async getAccessTokenFromCode(authData) {
const tokenUrl = 'https://github.com/login/oauth/access_token';
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: this.clientId,
client_secret: this.clientSecret,
code: authData.code,
}),
});
if (!response.ok) {
throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `Failed to exchange code for token: ${response.statusText}`);
} }
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Github auth is invalid for this user.');
}); const data = await response.json();
if (data.error) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, data.error_description || data.error);
}
return data.access_token;
}
async getUserFromAccessToken(accessToken) {
const userApiUrl = 'https://api.github.com/user';
const response = await fetch(userApiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Parse.Error(Parse.Error.VALIDATION_ERROR, `Failed to fetch GitHub user: ${response.statusText}`);
}
const userData = await response.json();
if (!userData.id || !userData.login) {
throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Invalid GitHub user data received.');
}
return userData;
}
} }
// Returns a promise that fulfills iff this app id is valid. export default new GitHubAdapter();
function validateAppId() {
return Promise.resolve();
}
// A promisey wrapper for api requests
function request(path, access_token) {
return httpsRequest.get({
host: 'api.github.com',
path: '/' + path,
headers: {
Authorization: 'bearer ' + access_token,
'User-Agent': 'parse-server',
},
});
}
module.exports = {
validateAppId: validateAppId,
validateAuthData: validateAuthData,
};

View File

@@ -1,3 +1,47 @@
/**
* Parse Server authentication adapter for Google.
*
* @class GoogleAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - Your Google application Client ID. Required for authentication.
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Google authentication, use the following structure:
* ```json
* {
* "auth": {
* "google": {
* "clientId": "your-client-id"
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **id**: The Google user ID.
* - **id_token**: The Google ID token.
* - **access_token**: The Google access token.
*
* ## Auth Payload
* ### Example Auth Data Payload
* ```json
* {
* "google": {
* "id": "1234567",
* "id_token": "xxxxx.yyyyy.zzzzz",
* "access_token": "abc123def456ghi789"
* }
* }
* ```
*
* ## Notes
* - Ensure your Google Client ID is configured properly in the Parse Server configuration.
* - The `id_token` and `access_token` are validated against Google's authentication services.
*
* @see {@link https://developers.google.com/identity/sign-in/web/backend-auth Google Authentication Documentation}
*/
'use strict'; 'use strict';
// Helper functions for accessing the google API. // Helper functions for accessing the google API.

View File

@@ -1,33 +1,139 @@
/* Google Play Game Services /**
https://developers.google.com/games/services/web/api/players/get * Parse Server authentication adapter for Google Play Games Services.
*
* @class GooglePlayGamesServicesAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - Your Google Play Games Services App Client ID. Required for secure authentication.
* @param {string} options.clientSecret - Your Google Play Games Services App Client Secret. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Google Play Games Services authentication, use the following structure:
* ```json
* {
* "auth": {
* "gpgames": {
* "clientId": "your-client-id",
* "clientSecret": "your-client-secret"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "gpgames": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **Secure Authentication**: `code`, `redirect_uri`.
* - **Insecure Authentication (Not Recommended)**: `id`, `access_token`.
*
* ## Auth Payloads
* ### Secure Authentication Payload
* ```json
* {
* "gpgames": {
* "code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
* "redirect_uri": "https://example.com/callback"
* }
* }
* ```
*
* ### Insecure Authentication Payload (Not Recommended)
* ```json
* {
* "gpgames": {
* "id": "123456789",
* "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
* }
* }
* ```
*
* ## Notes
* - `enableInsecureAuth` is **not recommended** and may be removed in future versions. Use secure authentication with `code` and `redirect_uri`.
* - Secure authentication exchanges the `code` provided by the client for an access token using Google Play Games Services' OAuth API.
*
* @see {@link https://developers.google.com/games/services/console/enabling Google Play Games Services Authentication Documentation}
*/
const authData = { import BaseCodeAuthAdapter from './BaseCodeAuthAdapter';
id: 'playerId', class GooglePlayGamesServicesAdapter extends BaseCodeAuthAdapter {
access_token: 'token', constructor() {
}; super("gpgames");
*/
const { Parse } = require('parse/node');
const httpsRequest = require('./httpsRequest');
// Returns a promise that fulfills if this user id is valid.
async function validateAuthData(authData) {
const response = await httpsRequest.get(
`https://www.googleapis.com/games/v1/players/${authData.id}?access_token=${authData.access_token}`
);
if (!(response && response.playerId === authData.id)) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Google Play Games Services - authData is invalid for this user.'
);
} }
async getAccessTokenFromCode(authData) {
const tokenUrl = 'https://oauth2.googleapis.com/token';
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: this.clientId,
client_secret: this.clientSecret,
code: authData.code,
redirect_uri: authData.redirectUri,
grant_type: 'authorization_code',
}),
});
if (!response.ok) {
throw new Parse.Error(
Parse.Error.VALIDATION_ERROR,
`Failed to exchange code for token: ${response.statusText}`
);
}
const data = await response.json();
if (data.error) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
data.error_description || data.error
);
}
return data.access_token;
}
async getUserFromAccessToken(accessToken, authData) {
const userApiUrl = `https://www.googleapis.com/games/v1/players/${authData.id}`;
const response = await fetch(userApiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Parse.Error(
Parse.Error.VALIDATION_ERROR,
`Failed to fetch Google Play Games Services user: ${response.statusText}`
);
}
const userData = await response.json();
if (!userData.playerId || userData.playerId !== authData.id) {
throw new Parse.Error(
Parse.Error.VALIDATION_ERROR,
'Invalid Google Play Games Services user data received.'
);
}
return {
id: userData.playerId
};
}
} }
// Returns a promise that fulfills if this app id is valid. export default new GooglePlayGamesServicesAdapter();
function validateAppId() {
return Promise.resolve();
}
module.exports = {
validateAppId,
validateAuthData,
};

View File

@@ -3,30 +3,31 @@ import Parse from 'parse/node';
import AuthAdapter from './AuthAdapter'; import AuthAdapter from './AuthAdapter';
const apple = require('./apple'); const apple = require('./apple');
const gcenter = require('./gcenter');
const gpgames = require('./gpgames');
const facebook = require('./facebook');
const instagram = require('./instagram');
const linkedin = require('./linkedin');
const meetup = require('./meetup');
import mfa from './mfa';
const google = require('./google');
const github = require('./github');
const twitter = require('./twitter');
const spotify = require('./spotify');
const digits = require('./twitter'); // digits tokens are validated by twitter const digits = require('./twitter'); // digits tokens are validated by twitter
const janrainengage = require('./janrainengage'); const facebook = require('./facebook');
import gcenter from './gcenter';
import github from './github';
const google = require('./google');
import gpgames from './gpgames';
import instagram from './instagram';
const janraincapture = require('./janraincapture'); const janraincapture = require('./janraincapture');
const line = require('./line'); const janrainengage = require('./janrainengage');
const vkontakte = require('./vkontakte');
const qq = require('./qq');
const wechat = require('./wechat');
const weibo = require('./weibo');
const oauth2 = require('./oauth2');
const phantauth = require('./phantauth');
const microsoft = require('./microsoft');
const keycloak = require('./keycloak'); const keycloak = require('./keycloak');
const ldap = require('./ldap'); const ldap = require('./ldap');
import line from './line';
import linkedin from './linkedin';
const meetup = require('./meetup');
import mfa from './mfa';
import microsoft from './microsoft';
import oauth2 from './oauth2';
const phantauth = require('./phantauth');
import qq from './qq';
import spotify from './spotify';
import twitter from './twitter';
const vkontakte = require('./vkontakte');
import wechat from './wechat';
import weibo from './weibo';
const anonymous = { const anonymous = {
validateAuthData: () => { validateAuthData: () => {
@@ -241,9 +242,9 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) {
}; };
const result = afterFind.call( const result = afterFind.call(
adapter, adapter,
requestObject,
authData[provider], authData[provider],
providerOptions providerOptions,
requestObject,
); );
if (result) { if (result) {
authData[provider] = result; authData[provider] = result;

View File

@@ -1,27 +1,121 @@
// Helper functions for accessing the instagram API. /**
var Parse = require('parse/node').Parse; * Parse Server authentication adapter for Instagram.
const httpsRequest = require('./httpsRequest'); *
const defaultURL = 'https://graph.instagram.com/'; * @class InstagramAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - Your Instagram App Client ID. Required for secure authentication.
* @param {string} options.clientSecret - Your Instagram App Client Secret. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Instagram authentication, use the following structure:
* ```json
* {
* "auth": {
* "instagram": {
* "clientId": "your-client-id",
* "clientSecret": "your-client-secret"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "instagram": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **Secure Authentication**: `code`, `redirect_uri`.
* - **Insecure Authentication (Deprecated)**: `id`, `access_token`.
*
* ## Auth Payloads
* ### Secure Authentication Payload
* ```json
* {
* "instagram": {
* "code": "lmn789opq012rst345uvw",
* "redirect_uri": "https://example.com/callback"
* }
* }
* ```
*
* ### Insecure Authentication Payload (Deprecated)
* ```json
* {
* "instagram": {
* "id": "1234567",
* "access_token": "AQXNnd2hIT6z9bHFzZz2Kp1ghiMz_RtyuvwXYZ123abc"
* }
* }
* ```
*
* ## Notes
* - `enableInsecureAuth` is **deprecated** and will be removed in future versions. Use secure authentication with `code` and `redirect_uri`.
* - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Instagram's OAuth flow.
*
* @see {@link https://developers.facebook.com/docs/instagram-basic-display-api/getting-started Instagram Basic Display API - Getting Started}
*/
// Returns a promise that fulfills if this user id is valid.
function validateAuthData(authData) { import BaseAuthCodeAdapter from './BaseCodeAuthAdapter';
const apiURL = authData.apiURL || defaultURL; class InstagramAdapter extends BaseAuthCodeAdapter {
const path = `${apiURL}me?fields=id&access_token=${authData.access_token}`; constructor() {
return httpsRequest.get(path).then(response => { super('Instagram');
const user = response.data ? response.data : response; }
if (user && user.id == authData.id) {
return; async getAccessTokenFromCode(authData) {
const response = await fetch('https://api.instagram.com/oauth/access_token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: 'authorization_code',
redirect_uri: this.redirectUri,
code: authData.code
})
});
if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.');
} }
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram auth is invalid for this user.');
}); const data = await response.json();
if (data.error) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, data.error_description || data.error);
}
return data.access_token;
}
async getUserFromAccessToken(accessToken, authData) {
const defaultURL = 'https://graph.instagram.com/';
const apiURL = authData.apiURL || defaultURL;
const path = `${apiURL}me?fields=id&access_token=${accessToken}`;
const response = await fetch(path);
if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram API request failed.');
}
const user = await response.json();
if (user?.id !== authData.id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Instagram auth is invalid for this user.');
}
return {
id: user.id,
}
}
} }
// Returns a promise that fulfills iff this app id is valid. export default new InstagramAdapter();
function validateAppId() {
return Promise.resolve();
}
module.exports = {
validateAppId,
validateAuthData,
};

View File

@@ -1,3 +1,48 @@
/**
* Parse Server authentication adapter for Janrain Capture API.
*
* @class JanrainCapture
* @param {Object} options - The adapter configuration options.
* @param {String} options.janrain_capture_host - The Janrain Capture API host.
*
* @param {Object} authData - The authentication data provided by the client.
* @param {String} authData.id - The Janrain Capture user ID.
* @param {String} authData.access_token - The Janrain Capture access token.
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Janrain Capture authentication, use the following structure:
* ```json
* {
* "auth": {
* "janrain": {
* "janrain_capture_host": "your-janrain-capture-host"
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - `id`: The Janrain Capture user ID.
* - `access_token`: An authorized Janrain Capture access token for the user.
*
* ## Auth Payload Example
* ```json
* {
* "janrain": {
* "id": "user's Janrain Capture ID as a string",
* "access_token": "an authorized Janrain Capture access token for the user"
* }
* }
* ```
*
* ## Notes
* Parse Server validates the provided `authData` using the Janrain Capture API.
*
* @see {@link https://docs.janrain.com/api/registration/entity/#entity Janrain Capture API Documentation}
*/
// Helper functions for accessing the Janrain Capture API. // Helper functions for accessing the Janrain Capture API.
var Parse = require('parse/node').Parse; var Parse = require('parse/node').Parse;
var querystring = require('querystring'); var querystring = require('querystring');

View File

@@ -2,9 +2,18 @@
var httpsRequest = require('./httpsRequest'); var httpsRequest = require('./httpsRequest');
var Parse = require('parse/node').Parse; var Parse = require('parse/node').Parse;
var querystring = require('querystring'); var querystring = require('querystring');
import Config from '../../Config';
import Deprecator from '../../Deprecator/Deprecator';
// Returns a promise that fulfills iff this user id is valid. // Returns a promise that fulfills iff this user id is valid.
function validateAuthData(authData, options) { function validateAuthData(authData, options) {
const config = Config.get(Parse.applicationId);
Deprecator.logRuntimeDeprecation({ usage: 'janrainengage adapter' });
if (!config?.auth?.janrainengage?.enableInsecureAuth || !config.enableInsecureAuthAdapters) {
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'janrainengage adapter only works with enableInsecureAuth: true');
}
return apiRequest(options.api_key, authData.auth_token).then(data => { return apiRequest(options.api_key, authData.auth_token).then(data => {
//successful response will have a "stat" (status) of 'ok' and a profile node with an identifier //successful response will have a "stat" (status) of 'ok' and a profile node with an identifier
//see: http://developers.janrain.com/overview/social-login/identity-providers/user-profile-data/#normalized-user-profile-data //see: http://developers.janrain.com/overview/social-login/identity-providers/user-profile-data/#normalized-user-profile-data

View File

@@ -1,37 +1,70 @@
/* /**
# Parse Server Keycloak Authentication * Parse Server authentication adapter for Keycloak.
*
## Keycloak `authData` * @class KeycloakAdapter
* @param {Object} options - The adapter configuration options.
``` * @param {Object} options.config - The Keycloak configuration object, typically loaded from a JSON file.
{ * @param {String} options.config.auth-server-url - The Keycloak authentication server URL.
"keycloak": { * @param {String} options.config.realm - The Keycloak realm name.
"access_token": "access token you got from keycloak JS client authentication", * @param {String} options.config.client-id - The Keycloak client ID.
"id": "the id retrieved from client authentication in Keycloak", *
"roles": ["the roles retrieved from client authentication in Keycloak"], * @param {Object} authData - The authentication data provided by the client.
"groups": ["the groups retrieved from client authentication in Keycloak"] * @param {String} authData.access_token - The Keycloak access token retrieved during client authentication.
} * @param {String} authData.id - The user ID retrieved from Keycloak during client authentication.
} * @param {Array} [authData.roles] - The roles assigned to the user in Keycloak (optional).
``` * @param {Array} [authData.groups] - The groups assigned to the user in Keycloak (optional).
*
The authentication module will test if the authData is the same as the * @description
userinfo oauth call, comparing the attributes * ## Parse Server Configuration
* To configure Parse Server for Keycloak authentication, use the following structure:
Copy the JSON config file generated on Keycloak (https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter) * ```javascript
and paste it inside of a folder (Ex.: `auth/keycloak.json`) in your server. * {
* "auth": {
The options passed to Parse server: * "keycloak": {
* "config": require('./auth/keycloak.json')
``` * }
{ * }
auth: { * }
keycloak: { * ```
config: require(`./auth/keycloak.json`) * Ensure the `keycloak.json` configuration file is generated from Keycloak's setup guide and includes:
} * - `auth-server-url`: The Keycloak authentication server URL.
} * - `realm`: The Keycloak realm name.
} * - `client-id`: The Keycloak client ID.
``` *
*/ * ## Auth Data
* The adapter requires the following `authData` fields:
* - `access_token`: The Keycloak access token retrieved during client authentication.
* - `id`: The user ID retrieved from Keycloak during client authentication.
* - `roles` (optional): The roles assigned to the user in Keycloak.
* - `groups` (optional): The groups assigned to the user in Keycloak.
*
* ## Auth Payload Example
* ### Example Auth Data
* ```json
* {
* "keycloak": {
* "access_token": "an authorized Keycloak access token for the user",
* "id": "user's Keycloak ID as a string",
* "roles": ["admin", "user"],
* "groups": ["group1", "group2"]
* }
* }
* ```
*
* ## Notes
* - Parse Server validates the provided `authData` by making a `userinfo` call to Keycloak and ensures the attributes match those returned by Keycloak.
*
* ## Keycloak Configuration
* To configure Keycloak, copy the JSON configuration file generated from Keycloak's setup guide:
* - [Keycloak Securing Apps Documentation](https://www.keycloak.org/docs/latest/securing_apps/index.html#_javascript_adapter)
*
* Place the configuration file on your server, for example:
* - `auth/keycloak.json`
*
* For more information on Keycloak authentication, see:
* - [Securing Apps Documentation](https://www.keycloak.org/docs/latest/securing_apps/)
* - [Server Administration Documentation](https://www.keycloak.org/docs/latest/server_admin/)
*/
const { Parse } = require('parse/node'); const { Parse } = require('parse/node');
const httpsRequest = require('./httpsRequest'); const httpsRequest = require('./httpsRequest');

View File

@@ -1,3 +1,78 @@
/**
* Parse Server authentication adapter for LDAP.
*
* @class LDAP
* @param {Object} options - The adapter configuration options.
* @param {String} options.url - The LDAP server URL. Must start with `ldap://` or `ldaps://`.
* @param {String} options.suffix - The LDAP suffix for user distinguished names (DN).
* @param {String} [options.dn] - The distinguished name (DN) template for user authentication. Replace `{{id}}` with the username.
* @param {Object} [options.tlsOptions] - Options for LDAPS TLS connections.
* @param {String} [options.groupCn] - The common name (CN) of the group to verify user membership.
* @param {String} [options.groupFilter] - The LDAP search filter for groups, with `{{id}}` replaced by the username.
*
* @param {Object} authData - The authentication data provided by the client.
* @param {String} authData.id - The user's LDAP username.
* @param {String} authData.password - The user's LDAP password.
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for LDAP authentication, use the following structure:
* ```javascript
* {
* auth: {
* ldap: {
* url: 'ldaps://ldap.example.com',
* suffix: 'ou=users,dc=example,dc=com',
* groupCn: 'admins',
* groupFilter: '(memberUid={{id}})',
* tlsOptions: {
* rejectUnauthorized: false
* }
* }
* }
* }
* ```
*
* ## Authentication Process
* 1. Validates the provided `authData` using an LDAP bind operation.
* 2. Optionally, verifies that the user belongs to a specific group by performing an LDAP search using the provided `groupCn` or `groupFilter`.
*
* ## Auth Payload
* The adapter requires the following `authData` fields:
* - `id`: The user's LDAP username.
* - `password`: The user's LDAP password.
*
* ### Example Auth Payload
* ```json
* {
* "ldap": {
* "id": "jdoe",
* "password": "password123"
* }
* }
* ```
*
* @example <caption>Configuration Example</caption>
* // Example Parse Server configuration:
* const config = {
* auth: {
* ldap: {
* url: 'ldaps://ldap.example.com',
* suffix: 'ou=users,dc=example,dc=com',
* groupCn: 'admins',
* groupFilter: '(memberUid={{id}})',
* tlsOptions: {
* rejectUnauthorized: false
* }
* }
* }
* };
*
* @see {@link https://ldap.com/ LDAP Basics}
* @see {@link https://ldap.com/ldap-filters/ LDAP Filters}
*/
const ldapjs = require('ldapjs'); const ldapjs = require('ldapjs');
const Parse = require('parse/node').Parse; const Parse = require('parse/node').Parse;

View File

@@ -1,36 +1,143 @@
// Helper functions for accessing the line API. /**
var Parse = require('parse/node').Parse; * Parse Server authentication adapter for Line.
const httpsRequest = require('./httpsRequest'); *
* @class LineAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - Your Line App Client ID. Required for secure authentication.
* @param {string} options.clientSecret - Your Line App Client Secret. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Line authentication, use the following structure:
* ### Secure Configuration
* ```json
* {
* "auth": {
* "line": {
* "clientId": "your-client-id",
* "clientSecret": "your-client-secret"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "line": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **Secure Authentication**: `code`, `redirect_uri`.
* - **Insecure Authentication (Not Recommended)**: `id`, `access_token`.
*
* ## Auth Payloads
* ### Secure Authentication Payload
* ```json
* {
* "line": {
* "code": "xxxxxxxxx",
* "redirect_uri": "https://example.com/callback"
* }
* }
* ```
*
* ### Insecure Authentication Payload (Not Recommended)
* ```json
* {
* "line": {
* "id": "1234567",
* "access_token": "xxxxxxxxx"
* }
* }
* ```
*
* ## Notes
* - `enableInsecureAuth` is **not recommended** and will be removed in future versions. Use secure authentication with `clientId` and `clientSecret`.
* - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Line's OAuth flow.
*
* @see {@link https://developers.line.biz/en/docs/line-login/integrate-line-login/ Line Login Documentation}
*/
// Returns a promise that fulfills if this user id is valid. import BaseCodeAuthAdapter from './BaseCodeAuthAdapter';
function validateAuthData(authData) {
return request('profile', authData.access_token).then(response => { class LineAdapter extends BaseCodeAuthAdapter {
if (response && response.userId && response.userId === authData.id) { constructor() {
return; super('Line');
}
async getAccessTokenFromCode(authData) {
if (!authData.code) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Line auth is invalid for this user.'
);
} }
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Line auth is invalid for this user.');
}); const tokenUrl = 'https://api.line.me/oauth2/v2.1/token';
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: 'authorization_code',
redirect_uri: authData.redirect_uri,
code: authData.code,
}),
});
if (!response.ok) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
`Failed to exchange code for token: ${response.statusText}`
);
}
const data = await response.json();
if (data.error) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
data.error_description || data.error
);
}
return data.access_token;
}
async getUserFromAccessToken(accessToken) {
const userApiUrl = 'https://api.line.me/v2/profile';
const response = await fetch(userApiUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
`Failed to fetch Line user: ${response.statusText}`
);
}
const userData = await response.json();
if (!userData?.userId) {
throw new Parse.Error(
Parse.Error.VALIDATION_ERROR,
'Invalid Line user data received.'
);
}
return userData;
}
} }
// Returns a promise that fulfills iff this app id is valid. export default new LineAdapter();
function validateAppId() {
return Promise.resolve();
}
// A promisey wrapper for api requests
function request(path, access_token) {
var options = {
host: 'api.line.me',
path: '/v2/' + path,
method: 'GET',
headers: {
Authorization: 'Bearer ' + access_token,
},
};
return httpsRequest.get(options);
}
module.exports = {
validateAppId: validateAppId,
validateAuthData: validateAuthData,
};

View File

@@ -1,40 +1,115 @@
// Helper functions for accessing the linkedin API. /**
var Parse = require('parse/node').Parse; * Parse Server authentication adapter for LinkedIn.
const httpsRequest = require('./httpsRequest'); *
* @class LinkedInAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - Your LinkedIn App Client ID. Required for secure authentication.
* @param {string} options.clientSecret - Your LinkedIn App Client Secret. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for LinkedIn authentication, use the following structure:
* ### Secure Configuration
* ```json
* {
* "auth": {
* "linkedin": {
* "clientId": "your-client-id",
* "clientSecret": "your-client-secret"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "linkedin": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **Secure Authentication**: `code`, `redirect_uri`, and optionally `is_mobile_sdk`.
* - **Insecure Authentication (Not Recommended)**: `id`, `access_token`, and optionally `is_mobile_sdk`.
*
* ## Auth Payloads
* ### Secure Authentication Payload
* ```json
* {
* "linkedin": {
* "code": "lmn789opq012rst345uvw",
* "redirect_uri": "https://your-redirect-uri.com/callback",
* "is_mobile_sdk": true
* }
* }
* ```
*
* ### Insecure Authentication Payload (Not Recommended)
* ```json
* {
* "linkedin": {
* "id": "7654321",
* "access_token": "AQXNnd2hIT6z9bHFzZz2Kp1ghiMz_RtyuvwXYZ123abc",
* "is_mobile_sdk": true
* }
* }
* ```
*
* ## Notes
* - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using LinkedIn's OAuth API.
* - Insecure authentication validates the user ID and access token directly, bypassing OAuth flows. This method is **not recommended** and may introduce security vulnerabilities.
* - `enableInsecureAuth` is **deprecated** and may be removed in future versions.
*
* @see {@link https://learn.microsoft.com/en-us/linkedin/shared/authentication/authentication LinkedIn Authentication Documentation}
*/
// Returns a promise that fulfills iff this user id is valid. import BaseAuthCodeAdapter from './BaseCodeAuthAdapter';
function validateAuthData(authData) { class LinkedInAdapter extends BaseAuthCodeAdapter {
return request('me', authData.access_token, authData.is_mobile_sdk).then(data => { constructor() {
if (data && data.id == authData.id) { super('LinkedIn');
return; }
} async getUserFromAccessToken(access_token, authData) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Linkedin auth is invalid for this user.'); const response = await fetch('https://api.linkedin.com/v2/me', {
}); headers: {
} Authorization: `Bearer ${access_token}`,
'x-li-format': 'json',
// Returns a promise that fulfills iff this app id is valid. 'x-li-src': authData?.is_mobile_sdk ? 'msdk' : undefined,
function validateAppId() { },
return Promise.resolve(); });
}
if (!response.ok) {
// A promisey wrapper for api requests throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn API request failed.');
function request(path, access_token, is_mobile_sdk) { }
var headers = {
Authorization: 'Bearer ' + access_token, return response.json();
'x-li-format': 'json', }
};
async getAccessTokenFromCode(authData) {
if (is_mobile_sdk) { const response = await fetch('https://www.linkedin.com/oauth/v2/accessToken', {
headers['x-li-src'] = 'msdk'; method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authData.code,
redirect_uri: authData.redirect_uri,
client_id: this.clientId,
client_secret: this.clientSecret,
}),
});
if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'LinkedIn API request failed.');
}
const json = await response.json();
return json.access_token;
} }
return httpsRequest.get({
host: 'api.linkedin.com',
path: '/v2/' + path,
headers: headers,
});
} }
module.exports = { export default new LinkedInAdapter();
validateAppId: validateAppId,
validateAuthData: validateAuthData,
};

View File

@@ -1,15 +1,24 @@
// Helper functions for accessing the meetup API. // Helper functions for accessing the meetup API.
var Parse = require('parse/node').Parse; var Parse = require('parse/node').Parse;
const httpsRequest = require('./httpsRequest'); const httpsRequest = require('./httpsRequest');
import Config from '../../Config';
import Deprecator from '../../Deprecator/Deprecator';
// Returns a promise that fulfills iff this user id is valid. // Returns a promise that fulfills iff this user id is valid.
function validateAuthData(authData) { async function validateAuthData(authData) {
return request('member/self', authData.access_token).then(data => { const config = Config.get(Parse.applicationId);
if (data && data.id == authData.id) { const meetupConfig = config.auth.meetup;
return;
} Deprecator.logRuntimeDeprecation({ usage: 'meetup adapter' });
if (!meetupConfig?.enableInsecureAuth) {
throw new Parse.Error('Meetup only works with enableInsecureAuth: true');
}
const data = await request('member/self', authData.access_token);
if (data?.id !== authData.id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Meetup auth is invalid for this user.'); throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Meetup auth is invalid for this user.');
}); }
} }
// Returns a promise that fulfills iff this app id is valid. // Returns a promise that fulfills iff this app id is valid.

View File

@@ -1,3 +1,81 @@
/**
* Parse Server authentication adapter for Multi-Factor Authentication (MFA).
*
* @class MFAAdapter
* @param {Object} options - The adapter options.
* @param {Array<String>} options.options - Supported MFA methods. Must include `"SMS"` or `"TOTP"`.
* @param {Number} [options.digits=6] - The number of digits for the one-time password (OTP). Must be between 4 and 10.
* @param {Number} [options.period=30] - The validity period of the OTP in seconds. Must be greater than 10.
* @param {String} [options.algorithm="SHA1"] - The algorithm used for TOTP generation. Defaults to `"SHA1"`.
* @param {Function} [options.sendSMS] - A callback function for sending SMS OTPs. Required if `"SMS"` is included in `options`.
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for MFA, use the following structure:
* ```javascript
* {
* auth: {
* mfa: {
* options: ["SMS", "TOTP"],
* digits: 6,
* period: 30,
* algorithm: "SHA1",
* sendSMS: (token, mobile) => {
* // Send the SMS using your preferred SMS provider.
* console.log(`Sending SMS to ${mobile} with token: ${token}`);
* }
* }
* }
* }
* ```
*
* ## MFA Methods
* - **SMS**:
* - Requires a valid mobile number.
* - Sends a one-time password (OTP) via SMS for login or verification.
* - Uses the `sendSMS` callback for sending the OTP.
*
* - **TOTP**:
* - Requires a secret key for setup.
* - Validates the user's OTP against a time-based one-time password (TOTP) generated using the secret key.
* - Supports configurable digits, period, and algorithm for TOTP generation.
*
* ## MFA Payload
* The adapter requires the following `authData` fields:
* - **For SMS-based MFA**:
* - `mobile`: The user's mobile number (required for setup).
* - `token`: The OTP provided by the user for login or verification.
* - **For TOTP-based MFA**:
* - `secret`: The TOTP secret key for the user (required for setup).
* - `token`: The OTP provided by the user for login or verification.
*
* ## Example Payloads
* ### SMS Setup Payload
* ```json
* {
* "mobile": "+1234567890"
* }
* ```
*
* ### TOTP Setup Payload
* ```json
* {
* "secret": "BASE32ENCODEDSECRET",
* "token": "123456"
* }
* ```
*
* ### Login Payload
* ```json
* {
* "token": "123456"
* }
* ```
*
* @see {@link https://en.wikipedia.org/wiki/Time-based_One-Time_Password_algorithm Time-based One-Time Password Algorithm (TOTP)}
* @see {@link https://tools.ietf.org/html/rfc6238 RFC 6238: TOTP: Time-Based One-Time Password Algorithm}
*/
import { TOTP, Secret } from 'otpauth'; import { TOTP, Secret } from 'otpauth';
import { randomString } from '../../cryptoUtils'; import { randomString } from '../../cryptoUtils';
import AuthAdapter from './AuthAdapter'; import AuthAdapter from './AuthAdapter';
@@ -113,7 +191,7 @@ class MFAAdapter extends AuthAdapter {
} }
throw 'Invalid MFA data'; throw 'Invalid MFA data';
} }
afterFind(req, authData) { afterFind(authData, options, req) {
if (req.master) { if (req.master) {
return; return;
} }

View File

@@ -1,37 +1,109 @@
// Helper functions for accessing the microsoft graph API. /**
var Parse = require('parse/node').Parse; * Parse Server authentication adapter for Microsoft.
const httpsRequest = require('./httpsRequest'); *
* @class MicrosoftAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - Your Microsoft App Client ID. Required for secure authentication.
* @param {string} options.clientSecret - Your Microsoft App Client Secret. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Microsoft authentication, use the following structure:
* ### Secure Configuration
* ```json
* {
* "auth": {
* "microsoft": {
* "clientId": "your-client-id",
* "clientSecret": "your-client-secret"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "microsoft": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **Secure Authentication**: `code`, `redirect_uri`.
* - **Insecure Authentication (Not Recommended)**: `id`, `access_token`.
*
* ## Auth Payloads
* ### Secure Authentication Payload
* ```json
* {
* "microsoft": {
* "code": "lmn789opq012rst345uvw",
* "redirect_uri": "https://your-redirect-uri.com/callback"
* }
* }
* ```
* ### Insecure Authentication Payload (Not Recommended)
* ```json
* {
* "microsoft": {
* "id": "7654321",
* "access_token": "AQXNnd2hIT6z9bHFzZz2Kp1ghiMz_RtyuvwXYZ123abc"
* }
* }
* ```
*
* ## Notes
* - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using Microsoft's OAuth API.
* - **Insecure authentication** validates the user ID and access token directly, bypassing OAuth flows (not recommended). This method is deprecated and may be removed in future versions.
*
* @see {@link https://docs.microsoft.com/en-us/graph/auth/auth-concepts Microsoft Authentication Documentation}
*/
// Returns a promise that fulfills if this user mail is valid. import BaseAuthCodeAdapter from './BaseCodeAuthAdapter';
function validateAuthData(authData) { class MicrosoftAdapter extends BaseAuthCodeAdapter {
return request('me', authData.access_token).then(response => { constructor() {
if (response && response.id && response.id == authData.id) { super('Microsoft');
return; }
async getUserFromAccessToken(access_token) {
const userResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: {
Authorization: 'Bearer ' + access_token,
},
});
if (!userResponse.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Microsoft API request failed.');
} }
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND, return userResponse.json();
'Microsoft Graph auth is invalid for this user.' }
);
}); async getAccessTokenFromCode(authData) {
const response = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: 'authorization_code',
redirect_uri: authData.redirect_uri,
code: authData.code,
}),
});
if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Microsoft API request failed.');
}
const json = await response.json();
return json.access_token;
}
} }
// Returns a promise that fulfills if this app id is valid. export default new MicrosoftAdapter();
function validateAppId() {
return Promise.resolve();
}
// A promisey wrapper for api requests
function request(path, access_token) {
return httpsRequest.get({
host: 'graph.microsoft.com',
path: '/v1.0/' + path,
headers: {
Authorization: 'Bearer ' + access_token,
},
});
}
module.exports = {
validateAppId: validateAppId,
validateAuthData: validateAuthData,
};

View File

@@ -1,137 +1,121 @@
/* /**
* This auth adapter is based on the OAuth 2.0 Token Introspection specification. * Parse Server authentication adapter for OAuth2 Token Introspection.
* See RFC 7662 for details (https://tools.ietf.org/html/rfc7662).
* It's purpose is to validate OAuth2 access tokens using the OAuth2 provider's
* token introspection endpoint (if implemented by the provider).
* *
* The adapter accepts the following config parameters: * @class OAuth2Adapter
* * @param {Object} options - The adapter configuration options.
* 1. "tokenIntrospectionEndpointUrl" (string, required) * @param {string} options.tokenIntrospectionEndpointUrl - The URL of the token introspection endpoint. Required.
* The URL of the token introspection endpoint of the OAuth2 provider that * @param {boolean} options.oauth2 - Indicates that the request should be handled by the OAuth2 adapter. Required.
* issued the access token to the client that is to be validated. * @param {string} [options.useridField] - The field in the introspection response that contains the user ID. Optional.
* * @param {string} [options.appidField] - The field in the introspection response that contains the app ID. Optional.
* 2. "useridField" (string, optional) * @param {string[]} [options.appIds] - List of allowed app IDs. Required if `appidField` is defined.
* The name of the field in the token introspection response that contains * @param {string} [options.authorizationHeader] - The Authorization header value for the introspection request. Optional.
* the userid. If specified, it will be used to verify the value of the "id"
* field in the "authData" JSON that is coming from the client.
* This can be the "aud" (i.e. audience), the "sub" (i.e. subject) or the
* "username" field in the introspection response, but since only the
* "active" field is required and all other reponse fields are optional
* in the RFC, it has to be optional in this adapter as well.
* Default: - (undefined)
*
* 3. "appidField" (string, optional)
* The name of the field in the token introspection response that contains
* the appId of the client. If specified, it will be used to verify it's
* value against the set of appIds in the adapter config. The concept of
* appIds comes from the two major social login providers
* (Google and Facebook). They have not yet implemented the token
* introspection endpoint, but the concept can be valid for any OAuth2
* provider.
* Default: - (undefined)
*
* 4. "appIds" (array of strings, required if appidField is defined)
* A set of appIds that are used to restrict accepted access tokens based
* on a specific field's value in the token introspection response.
* Default: - (undefined)
*
* 5. "authorizationHeader" (string, optional)
* The value of the "Authorization" HTTP header in requests sent to the
* introspection endpoint. It must contain the raw value.
* Thus if HTTP Basic authorization is to be used, it must contain the
* "Basic" string, followed by whitespace, then by the base64 encoded
* version of the concatenated <username> + ":" + <password> string.
* Eg. "Basic dXNlcm5hbWU6cGFzc3dvcmQ="
*
* The adapter expects requests with the following authData JSON:
* *
* @description
* ## Parse Server Configuration
* To configure Parse Server for OAuth2 Token Introspection, use the following structure:
* ```json
* { * {
* "someadapter": { * "auth": {
* "id": "user's OAuth2 provider-specific id as a string", * "oauth2Provider": {
* "access_token": "an authorized OAuth2 access token for the user", * "tokenIntrospectionEndpointUrl": "https://provider.com/introspect",
* "useridField": "sub",
* "appidField": "aud",
* "appIds": ["my-app-id"],
* "authorizationHeader": "Basic dXNlcm5hbWU6cGFzc3dvcmQ=",
* "oauth2": true
* }
* } * }
* } * }
* ```
*
* The adapter requires the following `authData` fields:
* - `id`: The user ID provided by the client.
* - `access_token`: The access token provided by the client.
*
* ## Auth Payload
* ### Example Auth Payload
* ```json
* {
* "oauth2": {
* "id": "user-id",
* "access_token": "access-token"
* }
* }
* ```
*
* ## Notes
* - `tokenIntrospectionEndpointUrl` is mandatory and should point to a valid OAuth2 provider's introspection endpoint.
* - If `appidField` is defined, `appIds` must also be specified to validate the app ID in the introspection response.
* - `authorizationHeader` can be used to authenticate requests to the token introspection endpoint.
*
* @see {@link https://datatracker.ietf.org/doc/html/rfc7662 OAuth 2.0 Token Introspection Specification}
*/ */
const Parse = require('parse/node').Parse;
const querystring = require('querystring');
const httpsRequest = require('./httpsRequest');
const INVALID_ACCESS = 'OAuth2 access token is invalid for this user.'; import AuthAdapter from './AuthAdapter';
const INVALID_ACCESS_APPID =
"OAuth2: the access_token's appID is empty or is not in the list of permitted appIDs in the auth configuration.";
const MISSING_APPIDS =
'OAuth2 configuration is missing the client app IDs ("appIds" config parameter).';
const MISSING_URL = 'OAuth2 token introspection endpoint URL is missing from configuration!';
// Returns a promise that fulfills if this user id is valid. class OAuth2Adapter extends AuthAdapter {
function validateAuthData(authData, options) { validateOptions(options) {
return requestTokenInfo(options, authData.access_token).then(response => { super.validateOptions(options);
if (
!response || if (!options.tokenIntrospectionEndpointUrl) {
!response.active || throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection endpoint URL is missing.');
(options.useridField && authData.id !== response[options.useridField]) }
) { if (options.appidField && !options.appIds?.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS); throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 configuration is missing app IDs.');
} }
});
}
function validateAppId(appIds, authData, options) { this.tokenIntrospectionEndpointUrl = options.tokenIntrospectionEndpointUrl;
if (!options || !options.appidField) { this.useridField = options.useridField;
return Promise.resolve(); this.appidField = options.appidField;
this.appIds = options.appIds;
this.authorizationHeader = options.authorizationHeader;
} }
if (!appIds || appIds.length === 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, MISSING_APPIDS); async validateAppId(authData) {
} if (!this.appidField) {
return requestTokenInfo(options, authData.access_token).then(response => {
if (!response || !response.active) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS);
}
const appidField = options.appidField;
if (!response[appidField]) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS_APPID);
}
const responseValue = response[appidField];
if (!Array.isArray(responseValue) && appIds.includes(responseValue)) {
return; return;
} else if (
Array.isArray(responseValue) &&
responseValue.some(appId => appIds.includes(appId))
) {
return;
} else {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, INVALID_ACCESS_APPID);
} }
});
const response = await this.requestTokenInfo(authData.access_token);
const appIdFieldValue = response[this.appidField];
const isValidAppId = Array.isArray(appIdFieldValue)
? appIdFieldValue.some(appId => this.appIds.includes(appId))
: this.appIds.includes(appIdFieldValue);
if (!isValidAppId) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2: Invalid app ID.');
}
}
async validateAuthData(authData) {
const response = await this.requestTokenInfo(authData.access_token);
if (!response.active || (this.useridField && authData.id !== response[this.useridField])) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 access token is invalid for this user.');
}
return {};
}
async requestTokenInfo(accessToken) {
const response = await fetch(this.tokenIntrospectionEndpointUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(this.authorizationHeader && { Authorization: this.authorizationHeader })
},
body: new URLSearchParams({ token: accessToken })
});
if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'OAuth2 token introspection request failed.');
}
return response.json();
}
} }
// A promise wrapper for requests to the OAuth2 token introspection endpoint. export default new OAuth2Adapter();
function requestTokenInfo(options, access_token) {
if (!options || !options.tokenIntrospectionEndpointUrl) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, MISSING_URL);
}
const parsedUrl = new URL(options.tokenIntrospectionEndpointUrl);
const postData = querystring.stringify({
token: access_token,
});
const headers = {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
};
if (options.authorizationHeader) {
headers['Authorization'] = options.authorizationHeader;
}
const postOptions = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname,
method: 'POST',
headers: headers,
};
return httpsRequest.request(postOptions, postData);
}
module.exports = {
validateAppId: validateAppId,
validateAuthData: validateAuthData,
};

View File

@@ -7,15 +7,24 @@
const { Parse } = require('parse/node'); const { Parse } = require('parse/node');
const httpsRequest = require('./httpsRequest'); const httpsRequest = require('./httpsRequest');
import Config from '../../Config';
import Deprecator from '../../Deprecator/Deprecator';
// Returns a promise that fulfills if this user id is valid. // Returns a promise that fulfills if this user id is valid.
function validateAuthData(authData) { async function validateAuthData(authData) {
return request('auth/userinfo', authData.access_token).then(data => { const config = Config.get(Parse.applicationId);
if (data && data.sub == authData.id) {
return; Deprecator.logRuntimeDeprecation({ usage: 'phantauth adapter' });
}
const phantauthConfig = config.auth.phantauth;
if (!phantauthConfig?.enableInsecureAuth) {
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'PhantAuth only works with enableInsecureAuth: true');
}
const data = await request('auth/userinfo', authData.access_token);
if (data?.sub !== authData.id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'PhantAuth auth is invalid for this user.'); throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'PhantAuth auth is invalid for this user.');
}); }
} }
// Returns a promise that fulfills if this app id is valid. // Returns a promise that fulfills if this app id is valid.

View File

@@ -1,41 +1,112 @@
// Helper functions for accessing the qq Graph API. /**
const httpsRequest = require('./httpsRequest'); * Parse Server authentication adapter for QQ.
var Parse = require('parse/node').Parse; *
* @class QqAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - Your QQ App ID. Required for secure authentication.
* @param {string} options.clientSecret - Your QQ App Secret. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for QQ authentication, use the following structure:
* ### Secure Configuration
* ```json
* {
* "auth": {
* "qq": {
* "clientId": "your-app-id",
* "clientSecret": "your-app-secret"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "qq": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **Secure Authentication**: `code`, `redirect_uri`.
* - **Insecure Authentication (Not Recommended)**: `id`, `access_token`.
*
* ## Auth Payloads
* ### Secure Authentication Payload
* ```json
* {
* "qq": {
* "code": "abcd1234",
* "redirect_uri": "https://your-redirect-uri.com/callback"
* }
* }
* ```
* ### Insecure Authentication Payload (Not Recommended)
* ```json
* {
* "qq": {
* "id": "1234567",
* "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
* }
* }
* ```
*
* ## Notes
* - Secure authentication exchanges the `code` and `redirect_uri` provided by the client for an access token using QQ's OAuth API.
* - **Insecure authentication** validates the `id` and `access_token` directly, bypassing OAuth flows. This approach is not recommended and may be deprecated in future versions.
*
* @see {@link https://wiki.connect.qq.com/ QQ Authentication Documentation}
*/
// Returns a promise that fulfills iff this user id is valid. import BaseAuthCodeAdapter from './BaseCodeAuthAdapter';
function validateAuthData(authData) { class QqAdapter extends BaseAuthCodeAdapter {
return graphRequest('me?access_token=' + authData.access_token).then(function (data) { constructor() {
if (data && data.openid == authData.id) { super('qq');
return; }
}
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq auth is invalid for this user.'); async getUserFromAccessToken(access_token) {
}); const response = await fetch('https://graph.qq.com/oauth2.0/me', {
} headers: {
Authorization: `Bearer ${access_token}`,
// Returns a promise that fulfills if this app id is valid. },
function validateAppId() { });
return Promise.resolve();
} if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq API request failed.');
// A promisey wrapper for qq graph requests. }
function graphRequest(path) {
return httpsRequest.get('https://graph.qq.com/oauth2.0/' + path, true).then(data => { const data = await response.text();
return parseResponseData(data); return this.parseResponseData(data);
}); }
}
async getAccessTokenFromCode(authData) {
function parseResponseData(data) { const response = await fetch('https://graph.qq.com/oauth2.0/token', {
const starPos = data.indexOf('('); method: 'GET',
const endPos = data.indexOf(')'); headers: {
if (starPos == -1 || endPos == -1) { 'Content-Type': 'application/x-www-form-urlencoded',
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq auth is invalid for this user.'); },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.clientId,
client_secret: this.clientSecret,
redirect_uri: authData.redirect_uri,
code: authData.code,
}).toString(),
});
if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'qq API request failed.');
}
const text = await response.text();
const data = this.parseResponseData(text);
return data.access_token;
} }
data = data.substring(starPos + 1, endPos - 1);
return JSON.parse(data);
} }
module.exports = { export default new QqAdapter();
validateAppId,
validateAuthData,
parseResponseData,
};

View File

@@ -1,44 +1,118 @@
// Helper functions for accessing the Spotify API. /**
const httpsRequest = require('./httpsRequest'); * Parse Server authentication adapter for Spotify.
var Parse = require('parse/node').Parse; *
* @class SpotifyAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.clientId - Your Spotify application's Client ID. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Spotify authentication, use the following structure:
* ### Secure Configuration
* ```json
* {
* "auth": {
* "spotify": {
* "clientId": "your-client-id"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "spotify": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **Secure Authentication**: `code`, `redirect_uri`, and `code_verifier`.
* - **Insecure Authentication (Not Recommended)**: `id`, `access_token`.
*
* ## Auth Payloads
* ### Secure Authentication Payload
* ```json
* {
* "spotify": {
* "code": "abc123def456ghi789",
* "redirect_uri": "https://example.com/callback",
* "code_verifier": "secure-code-verifier"
* }
* }
* ```
* ### Insecure Authentication Payload (Not Recommended)
* ```json
* {
* "spotify": {
* "id": "1234567",
* "access_token": "abc123def456ghi789"
* }
* }
* ```
*
* ## Notes
* - `enableInsecureAuth` is **not recommended** and bypasses secure flows by validating the user ID and access token directly. This method is not suitable for production environments and may be removed in future versions.
* - Secure authentication exchanges the `code` provided by the client for an access token using Spotify's OAuth API. This method ensures greater security and is the recommended approach.
*
* @see {@link https://developer.spotify.com/documentation/web-api/tutorials/getting-started Spotify OAuth Documentation}
*/
// Returns a promise that fulfills iff this user id is valid. import BaseAuthCodeAdapter from './BaseCodeAuthAdapter';
function validateAuthData(authData) { class SpotifyAdapter extends BaseAuthCodeAdapter {
return request('me', authData.access_token).then(data => { constructor() {
if (data && data.id == authData.id) { super('spotify');
return; }
async getUserFromAccessToken(access_token) {
const response = await fetch('https://api.spotify.com/v1/me', {
headers: {
Authorization: 'Bearer ' + access_token,
},
});
if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify API request failed.');
} }
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify auth is invalid for this user.');
});
}
// Returns a promise that fulfills if this app id is valid. const user = await response.json();
async function validateAppId(appIds, authData) { return {
const access_token = authData.access_token; id: user.id,
if (!Array.isArray(appIds)) { };
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'appIds must be an array.');
} }
if (!appIds.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify auth is not configured.'); async getAccessTokenFromCode(authData) {
} if (!authData.code || !authData.redirect_uri || !authData.code_verifier) {
const data = await request('me', access_token); throw new Parse.Error(
if (!data || !appIds.includes(data.id)) { Parse.Error.OBJECT_NOT_FOUND,
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify auth is invalid for this user.'); 'Spotify auth configuration authData.code and/or authData.redirect_uri and/or authData.code_verifier.'
);
}
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authData.code,
redirect_uri: authData.redirect_uri,
code_verifier: authData.code_verifier,
client_id: this.clientId,
}),
});
if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Spotify API request failed.');
}
return response.json();
} }
} }
// A promisey wrapper for Spotify API requests. export default new SpotifyAdapter();
function request(path, access_token) {
return httpsRequest.get({
host: 'api.spotify.com',
path: '/v1/' + path,
headers: {
Authorization: 'Bearer ' + access_token,
},
});
}
module.exports = {
validateAppId: validateAppId,
validateAuthData: validateAuthData,
};

View File

@@ -1,51 +1,244 @@
// Helper functions for accessing the twitter API. /**
var OAuth = require('./OAuth1Client'); * Parse Server authentication adapter for Twitter.
var Parse = require('parse/node').Parse; *
* @class TwitterAdapter
* @param {Object} options - The adapter configuration options.
* @param {string} options.consumerKey - The Twitter App Consumer Key. Required for secure authentication.
* @param {string} options.consumerSecret - The Twitter App Consumer Secret. Required for secure authentication.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Twitter authentication, use the following structure:
* ### Secure Configuration
* ```json
* {
* "auth": {
* "twitter": {
* "consumerKey": "your-consumer-key",
* "consumerSecret": "your-consumer-secret"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "twitter": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **Secure Authentication**: `oauth_token`, `oauth_verifier`.
* - **Insecure Authentication (Not Recommended)**: `id`, `oauth_token`, `oauth_token_secret`.
*
* ## Auth Payloads
* ### Secure Authentication Payload
* ```json
* {
* "twitter": {
* "oauth_token": "1234567890-abc123def456",
* "oauth_verifier": "abc123def456"
* }
* }
* ```
*
* ### Insecure Authentication Payload (Not Recommended)
* ```json
* {
* "twitter": {
* "id": "1234567890",
* "oauth_token": "1234567890-abc123def456",
* "oauth_token_secret": "1234567890-abc123def456"
* }
* }
* ```
*
* ## Notes
* - **Deprecation Notice**: `enableInsecureAuth` and insecure fields (`id`, `oauth_token_secret`) are **deprecated** and may be removed in future versions. Use secure authentication with `consumerKey` and `consumerSecret`.
* - Secure authentication exchanges the `oauth_token` and `oauth_verifier` provided by the client for an access token using Twitter's OAuth API.
*
* @see {@link https://developer.twitter.com/en/docs/authentication/oauth-1-0a Twitter OAuth Documentation}
*/
// Returns a promise that fulfills iff this user id is valid. import Config from '../../Config';
function validateAuthData(authData, options) { import querystring from 'querystring';
if (!options) { import AuthAdapter from './AuthAdapter';
throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Twitter auth configuration missing');
class TwitterAuthAdapter extends AuthAdapter {
validateOptions(options) {
if (!options) {
throw new Error('Twitter auth options are required.');
}
this.enableInsecureAuth = options.enableInsecureAuth;
if (!this.enableInsecureAuth && (!options.consumer_key || !options.consumer_secret)) {
throw new Error('Consumer key and secret are required for secure Twitter auth.');
}
} }
options = handleMultipleConfigurations(authData, options);
var client = new OAuth(options);
client.host = 'api.twitter.com';
client.auth_token = authData.auth_token;
client.auth_token_secret = authData.auth_token_secret;
return client.get('/1.1/account/verify_credentials.json').then(data => { async validateAuthData(authData, options) {
if (data && data.id_str == '' + authData.id) { const config = Config.get(Parse.applicationId);
const twitterConfig = config.auth.twitter;
if (this.enableInsecureAuth && twitterConfig && config.enableInsecureAuthAdapters) {
return this.validateInsecureAuth(authData, options);
}
if (!options.consumer_key || !options.consumer_secret) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Twitter auth configuration missing consumer_key and/or consumer_secret.'
);
}
const accessTokenData = await this.exchangeAccessToken(authData);
if (accessTokenData?.oauth_token && accessTokenData?.user_id) {
authData.id = accessTokenData.user_id;
authData.auth_token = accessTokenData.oauth_token;
return; return;
} }
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.');
});
}
// Returns a promise that fulfills iff this app id is valid. throw new Parse.Error(
function validateAppId() { Parse.Error.OBJECT_NOT_FOUND,
return Promise.resolve(); 'Twitter auth is invalid for this user.'
} );
}
function handleMultipleConfigurations(authData, options) { async validateInsecureAuth(authData, options) {
if (Array.isArray(options)) { if (!authData.oauth_token || !authData.oauth_token_secret) {
const consumer_key = authData.consumer_key; throw new Parse.Error(
if (!consumer_key) { Parse.Error.OBJECT_NOT_FOUND,
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); 'Twitter insecure auth requires oauth_token and oauth_token_secret.'
);
} }
options = options.filter(option => {
return option.consumer_key == consumer_key; options = this.handleMultipleConfigurations(authData, options);
const data = await this.request(authData, options);
const parsedData = await data.json();
if (parsedData?.id === authData.id) {
return;
}
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Twitter auth is invalid for this user.'
);
}
async exchangeAccessToken(authData) {
const accessTokenRequestOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: querystring.stringify({
oauth_token: authData.oauth_token,
oauth_verifier: authData.oauth_verifier,
}),
};
const response = await fetch('https://api.twitter.com/oauth/access_token', accessTokenRequestOptions);
if (!response.ok) {
throw new Error('Failed to exchange access token.');
}
return response.json();
}
handleMultipleConfigurations(authData, options) {
if (Array.isArray(options)) {
const consumer_key = authData.consumer_key;
if (!consumer_key) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Twitter auth is invalid for this user.'
);
}
options = options.filter(option => option.consumer_key === consumer_key);
if (options.length === 0) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Twitter auth is invalid for this user.'
);
}
return options[0];
}
return options;
}
async request(authData, options) {
const { consumer_key, consumer_secret } = options;
const oauth = {
consumer_key,
consumer_secret,
auth_token: authData.oauth_token,
auth_token_secret: authData.oauth_token_secret,
};
const url = new URL('https://api.twitter.com/2/users/me');
const response = await fetch(url, {
headers: {
Authorization: 'Bearer ' + oauth.auth_token,
},
body: JSON.stringify(oauth),
}); });
if (options.length == 0) { if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.'); throw new Error('Failed to fetch user data.');
} }
options = options[0];
return response;
}
async beforeFind(authData) {
if (this.enableInsecureAuth && !authData?.code) {
if (!authData?.access_token) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.');
}
const user = await this.getUserFromAccessToken(authData.access_token, authData);
if (user.id !== authData.id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Twitter auth is invalid for this user.');
}
return;
}
if (!authData?.code) {
throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Twitter code is required.');
}
const access_token = await this.exchangeAccessToken(authData);
const user = await this.getUserFromAccessToken(access_token, authData);
authData.access_token = access_token;
authData.id = user.id;
delete authData.code;
delete authData.redirect_uri;
}
validateAppId() {
return Promise.resolve();
} }
return options;
} }
module.exports = { export default new TwitterAuthAdapter();
validateAppId,
validateAuthData,
handleMultipleConfigurations,
};

View File

@@ -4,28 +4,32 @@
const httpsRequest = require('./httpsRequest'); const httpsRequest = require('./httpsRequest');
var Parse = require('parse/node').Parse; var Parse = require('parse/node').Parse;
import Config from '../../Config';
import Deprecator from '../../Deprecator/Deprecator';
// Returns a promise that fulfills iff this user id is valid. // Returns a promise that fulfills iff this user id is valid.
function validateAuthData(authData, params) { async function validateAuthData(authData, params) {
return vkOAuth2Request(params).then(function (response) { const config = Config.get(Parse.applicationId);
if (response && response.access_token) { Deprecator.logRuntimeDeprecation({ usage: 'vkontakte adapter' });
return request(
'api.vk.com', const vkConfig = config.auth.vkontakte;
'method/users.get?access_token=' + authData.access_token + '&v=' + params.apiVersion if (!vkConfig?.enableInsecureAuth || !config.enableInsecureAuthAdapters) {
).then(function (response) { throw new Parse.Error('Vk only works with enableInsecureAuth: true');
if ( }
response &&
response.response && const response = await vkOAuth2Request(params);
response.response.length && if (!response?.access_token) {
response.response[0].id == authData.id
) {
return;
}
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk auth is invalid for this user.');
});
}
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk appIds or appSecret is incorrect.'); throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk appIds or appSecret is incorrect.');
}); }
const vkUser = await request(
'api.vk.com',
`method/users.get?access_token=${authData.access_token}&v=${params.apiVersion}`
);
if (!vkUser?.response?.length || vkUser.response[0].id !== authData.id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Vk auth is invalid for this user.');
}
} }
function vkOAuth2Request(params) { function vkOAuth2Request(params) {

View File

@@ -1,30 +1,120 @@
// Helper functions for accessing the WeChat Graph API. /**
const httpsRequest = require('./httpsRequest'); * Parse Server authentication adapter for WeChat.
var Parse = require('parse/node').Parse; *
* @class WeChatAdapter
* @param {Object} options - The adapter options object.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
* @param {string} options.clientId - Your WeChat App ID.
* @param {string} options.clientSecret - Your WeChat App Secret.
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for WeChat authentication, use the following structure:
* ### Secure Configuration (Recommended)
* ```json
* {
* "auth": {
* "wechat": {
* "clientId": "your-client-id",
* "clientSecret": "your-client-secret"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "wechat": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **With `enableInsecureAuth` (Not Recommended)**: `id`, `access_token`.
* - **Without `enableInsecureAuth`**: `code`.
*
* ## Auth Payloads
* ### Secure Authentication Payload (Recommended)
* ```json
* {
* "wechat": {
* "code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
* }
* }
* ```
* ### Insecure Authentication Payload (Not Recommended)
* ```json
* {
* "wechat": {
* "id": "1234567",
* "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
* }
* }
* ```
*
* ## Notes
* - With `enableInsecureAuth`, the adapter directly validates the `id` and `access_token` sent by the client.
* - Without `enableInsecureAuth`, the adapter uses the `code` provided by the client to exchange for an access token via WeChat's OAuth API.
* - The `enableInsecureAuth` flag is **deprecated** and may be removed in future versions. Use secure authentication with the `code` field instead.
*
* @example <caption>Auth Data Example</caption>
* // Example authData provided by the client:
* const authData = {
* wechat: {
* code: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
* }
* };
*
* @see {@link https://developers.weixin.qq.com/doc/offiaccount/en/OA_Web_Apps/Wechat_webpage_authorization.html WeChat Authentication Documentation}
*/
// Returns a promise that fulfills iff this user id is valid. import BaseAuthCodeAdapter from './BaseCodeAuthAdapter';
function validateAuthData(authData) {
return graphRequest('auth?access_token=' + authData.access_token + '&openid=' + authData.id).then( class WeChatAdapter extends BaseAuthCodeAdapter {
function (data) { constructor() {
if (data.errcode == 0) { super('WeChat');
return; }
}
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'wechat auth is invalid for this user.'); async getUserFromAccessToken(access_token, authData) {
const response = await fetch(
`https://api.weixin.qq.com/sns/auth?access_token=${access_token}&openid=${authData.id}`
);
const data = await response.json();
if (!response.ok || data.errcode !== 0) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'WeChat auth is invalid for this user.');
} }
);
return data;
}
async getAccessTokenFromCode(authData) {
if (!authData.code) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'WeChat auth requires a code to be sent.');
}
const appId = this.clientId;
const appSecret = this.clientSecret
const response = await fetch(
`https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appId}&secret=${appSecret}&code=${authData.code}&grant_type=authorization_code`
);
const data = await response.json();
if (!response.ok || data.errcode) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'WeChat auth is invalid for this user.');
}
authData.id = data.openid;
return data.access_token;
}
} }
// Returns a promise that fulfills if this app id is valid. export default new WeChatAdapter();
function validateAppId() {
return Promise.resolve();
}
// A promisey wrapper for WeChat graph requests.
function graphRequest(path) {
return httpsRequest.get('https://api.weixin.qq.com/sns/' + path);
}
module.exports = {
validateAppId,
validateAuthData,
};

View File

@@ -1,41 +1,149 @@
// Helper functions for accessing the weibo Graph API. /**
var httpsRequest = require('./httpsRequest'); * Parse Server authentication adapter for Weibo.
var Parse = require('parse/node').Parse; *
var querystring = require('querystring'); * @class WeiboAdapter
* @param {Object} options - The adapter configuration options.
* @param {boolean} [options.enableInsecureAuth=false] - **[DEPRECATED]** Enable insecure authentication (not recommended).
* @param {string} options.clientId - Your Weibo client ID.
* @param {string} options.clientSecret - Your Weibo client secret.
*
* @description
* ## Parse Server Configuration
* To configure Parse Server for Weibo authentication, use the following structure:
* ### Secure Configuration
* ```json
* {
* "auth": {
* "weibo": {
* "clientId": "your-client-id",
* "clientSecret": "your-client-secret"
* }
* }
* }
* ```
* ### Insecure Configuration (Not Recommended)
* ```json
* {
* "auth": {
* "weibo": {
* "enableInsecureAuth": true
* }
* }
* }
* ```
*
* The adapter requires the following `authData` fields:
* - **Secure Authentication**: `code`, `redirect_uri`.
* - **Insecure Authentication (Not Recommended)**: `id`, `access_token`.
*
* ## Auth Payloads
* ### Secure Authentication Payload
* ```json
* {
* "weibo": {
* "code": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
* "redirect_uri": "https://example.com/callback"
* }
* }
* ```
* ### Insecure Authentication Payload (Not Recommended)
* ```json
* {
* "weibo": {
* "id": "1234567",
* "access_token": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
* }
* }
* ```
*
* ## Notes
* - **Insecure Authentication**: When `enableInsecureAuth` is enabled, the adapter directly validates the `id` and `access_token` provided by the client.
* - **Secure Authentication**: When `enableInsecureAuth` is disabled, the adapter exchanges the `code` and `redirect_uri` for an access token using Weibo's OAuth API.
* - `enableInsecureAuth` is **deprecated** and may be removed in future versions. Use secure authentication with `code` and `redirect_uri`.
*
* @example <caption>Auth Data Example (Secure)</caption>
* const authData = {
* weibo: {
* code: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
* redirect_uri: "https://example.com/callback"
* }
* };
*
* @example <caption>Auth Data Example (Insecure - Not Recommended)</caption>
* const authData = {
* weibo: {
* id: "1234567",
* access_token: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
* }
* };
*
* @see {@link https://open.weibo.com/wiki/Oauth2/access_token Weibo Authentication Documentation}
*/
// Returns a promise that fulfills iff this user id is valid. import BaseAuthCodeAdapter from './BaseCodeAuthAdapter';
function validateAuthData(authData) { import querystring from 'querystring';
return graphRequest(authData.access_token).then(function (data) {
if (data && data.uid == authData.id) { class WeiboAdapter extends BaseAuthCodeAdapter {
return; constructor() {
super('Weibo');
}
async getUserFromAccessToken(access_token) {
const postData = querystring.stringify({
access_token: access_token,
});
const response = await fetch('https://api.weibo.com/oauth2/get_token_info', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: postData,
});
const data = await response.json();
if (!response.ok) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Weibo auth is invalid for this user.');
} }
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'weibo auth is invalid for this user.');
}); return {
id: data.uid,
}
}
async getAccessTokenFromCode(authData) {
if (!authData?.code || !authData?.redirect_uri) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Weibo auth requires code and redirect_uri to be sent.'
);
}
const postData = querystring.stringify({
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: 'authorization_code',
code: authData.code,
redirect_uri: authData.redirect_uri,
});
const response = await fetch('https://api.weibo.com/oauth2/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: postData,
});
const data = await response.json();
if (!response.ok || data.errcode) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Weibo auth is invalid for this user.');
}
return data.access_token;
}
} }
// Returns a promise that fulfills if this app id is valid. export default new WeiboAdapter();
function validateAppId() {
return Promise.resolve();
}
// A promisey wrapper for weibo graph requests.
function graphRequest(access_token) {
var postData = querystring.stringify({
access_token: access_token,
});
var options = {
hostname: 'api.weibo.com',
path: '/oauth2/get_token_info',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData),
},
};
return httpsRequest.request(options, postData);
}
module.exports = {
validateAppId,
validateAuthData,
};

View File

@@ -417,26 +417,35 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer
}); });
}; };
const findUsersWithAuthData = (config, authData) => { const findUsersWithAuthData = async (config, authData, beforeFind) => {
const providers = Object.keys(authData); const providers = Object.keys(authData);
const query = providers
.reduce((memo, provider) => {
if (!authData[provider] || (authData && !authData[provider].id)) {
return memo;
}
const queryKey = `authData.${provider}.id`;
const query = {};
query[queryKey] = authData[provider].id;
memo.push(query);
return memo;
}, [])
.filter(q => {
return typeof q !== 'undefined';
});
return query.length > 0 const queries = await Promise.all(
? config.database.find('_User', { $or: query }, { limit: 2 }) providers.map(async provider => {
: Promise.resolve([]); const providerAuthData = authData[provider];
const adapter = config.authDataManager.getValidatorForProvider(provider)?.adapter;
if (beforeFind && typeof adapter?.beforeFind === 'function') {
await adapter.beforeFind(providerAuthData);
}
if (!providerAuthData?.id) {
return null;
}
return { [`authData.${provider}.id`]: providerAuthData.id };
})
);
// Filter out null queries
const validQueries = queries.filter(query => query !== null);
if (!validQueries.length) {
return [];
}
// Perform database query
return config.database.find('_User', { $or: validQueries }, { limit: 2 });
}; };
const hasMutatedAuthData = (authData, userAuthData) => { const hasMutatedAuthData = (authData, userAuthData) => {
@@ -539,7 +548,7 @@ const handleAuthDataValidation = async (authData, req, foundUser) => {
acc.authData[provider] = null; acc.authData[provider] = null;
continue; continue;
} }
const { validator } = req.config.authDataManager.getValidatorForProvider(provider); const { validator } = req.config.authDataManager.getValidatorForProvider(provider) || {};
const authProvider = (req.config.auth || {})[provider] || {}; const authProvider = (req.config.auth || {})[provider] || {};
if (!validator || authProvider.enabled === false) { if (!validator || authProvider.enabled === false) {
throw new Parse.Error( throw new Parse.Error(

View File

@@ -20,6 +20,7 @@ import {
SecurityOptions, SecurityOptions,
} from './Options/Definitions'; } from './Options/Definitions';
import ParseServer from './cloud-code/Parse.Server'; import ParseServer from './cloud-code/Parse.Server';
import Deprecator from './Deprecator/Deprecator';
function removeTrailingSlash(str) { function removeTrailingSlash(str) {
if (!str) { if (!str) {
@@ -84,6 +85,7 @@ export class Config {
pages, pages,
security, security,
enforcePrivateUsers, enforcePrivateUsers,
enableInsecureAuthAdapters,
schema, schema,
requestKeywordDenylist, requestKeywordDenylist,
allowExpiredAuthDataToken, allowExpiredAuthDataToken,
@@ -129,6 +131,7 @@ export class Config {
this.validateSecurityOptions(security); this.validateSecurityOptions(security);
this.validateSchemaOptions(schema); this.validateSchemaOptions(schema);
this.validateEnforcePrivateUsers(enforcePrivateUsers); this.validateEnforcePrivateUsers(enforcePrivateUsers);
this.validateEnableInsecureAuthAdapters(enableInsecureAuthAdapters);
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken); this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
this.validateRequestKeywordDenylist(requestKeywordDenylist); this.validateRequestKeywordDenylist(requestKeywordDenylist);
this.validateRateLimit(rateLimit); this.validateRateLimit(rateLimit);
@@ -504,6 +507,15 @@ export class Config {
} }
} }
static validateEnableInsecureAuthAdapters(enableInsecureAuthAdapters) {
if (enableInsecureAuthAdapters && typeof enableInsecureAuthAdapters !== 'boolean') {
throw 'Parse Server option enableInsecureAuthAdapters must be a boolean.';
}
if (enableInsecureAuthAdapters) {
Deprecator.logRuntimeDeprecation({ usage: 'insecure adapter' });
}
}
get mount() { get mount() {
var mount = this._mount; var mount = this._mount;
if (this.publicServerURL) { if (this.publicServerURL) {

View File

@@ -15,4 +15,7 @@
* *
* If there are no deprecations, this must return an empty array. * If there are no deprecations, this must return an empty array.
*/ */
module.exports = [{ optionKey: 'encodeParseObjectInCloudFunction', changeNewKey: '' }] module.exports = [
{ optionKey: 'encodeParseObjectInCloudFunction', changeNewDefault: 'true' },
{ optionKey: 'enableInsecureAuthAdapters', changeNewDefault: 'false' },
];

View File

@@ -233,6 +233,13 @@ module.exports.ParseServerOptions = {
action: parsers.booleanParser, action: parsers.booleanParser,
default: false, default: false,
}, },
enableInsecureAuthAdapters: {
env: 'PARSE_SERVER_ENABLE_INSECURE_AUTH_ADAPTERS',
help:
'Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them.',
action: parsers.booleanParser,
default: true,
},
encodeParseObjectInCloudFunction: { encodeParseObjectInCloudFunction: {
env: 'PARSE_SERVER_ENCODE_PARSE_OBJECT_IN_CLOUD_FUNCTION', env: 'PARSE_SERVER_ENCODE_PARSE_OBJECT_IN_CLOUD_FUNCTION',
help: help:

View File

@@ -43,6 +43,7 @@
* @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true * @property {Boolean} enableAnonymousUsers Enable (or disable) anonymous users, defaults to true
* @property {Boolean} enableCollationCaseComparison Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`. * @property {Boolean} enableCollationCaseComparison Optional. If set to `true`, the collation rule of case comparison for queries and indexes is enabled. Enable this option to run Parse Server with MongoDB Atlas Serverless or AWS Amazon DocumentDB. If `false`, the collation rule of case comparison is disabled. Default is `false`.
* @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors * @property {Boolean} enableExpressErrorHandler Enables the default express error handler for all errors
* @property {Boolean} enableInsecureAuthAdapters Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them.
* @property {Boolean} encodeParseObjectInCloudFunction If set to `true`, a `Parse.Object` that is in the payload when calling a Cloud Function will be converted to an instance of `Parse.Object`. If `false`, the object will not be converted and instead be a plain JavaScript object, which contains the raw data of a `Parse.Object` but is not an actual instance of `Parse.Object`. Default is `false`. <br><br> The expected behavior would be that the object is converted to an instance of `Parse.Object`, so you would normally set this option to `true`. The default is `false` because this is a temporary option that has been introduced to avoid a breaking change when fixing a bug where JavaScript objects are not converted to actual instances of `Parse.Object`. * @property {Boolean} encodeParseObjectInCloudFunction If set to `true`, a `Parse.Object` that is in the payload when calling a Cloud Function will be converted to an instance of `Parse.Object`. If `false`, the object will not be converted and instead be a plain JavaScript object, which contains the raw data of a `Parse.Object` but is not an actual instance of `Parse.Object`. Default is `false`. <br><br> The expected behavior would be that the object is converted to an instance of `Parse.Object`, so you would normally set this option to `true`. The default is `false` because this is a temporary option that has been introduced to avoid a breaking change when fixing a bug where JavaScript objects are not converted to actual instances of `Parse.Object`.
* @property {String} encryptionKey Key for encrypting your files * @property {String} encryptionKey Key for encrypting your files
* @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access. * @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access.

View File

@@ -161,6 +161,10 @@ export interface ParseServerOptions {
/* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication /* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication
:ENV: PARSE_SERVER_AUTH_PROVIDERS */ :ENV: PARSE_SERVER_AUTH_PROVIDERS */
auth: ?{ [string]: AuthAdapter }; auth: ?{ [string]: AuthAdapter };
/* Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them.
:ENV: PARSE_SERVER_ENABLE_INSECURE_AUTH_ADAPTERS
:DEFAULT: true */
enableInsecureAuthAdapters: ?boolean;
/* Max file size for uploads, defaults to 20mb /* Max file size for uploads, defaults to 20mb
:DEFAULT: 20mb */ :DEFAULT: 20mb */
maxUploadSize: ?string; maxUploadSize: ?string;

View File

@@ -458,9 +458,8 @@ RestWrite.prototype.validateAuthData = function () {
var providers = Object.keys(authData); var providers = Object.keys(authData);
if (providers.length > 0) { if (providers.length > 0) {
const canHandleAuthData = providers.some(provider => { const canHandleAuthData = providers.some(provider => {
var providerAuthData = authData[provider]; const providerAuthData = authData[provider] || {};
var hasToken = providerAuthData && providerAuthData.id; return !!Object.keys(providerAuthData).length;
return hasToken || providerAuthData === null;
}); });
if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) {
return this.handleAuthData(authData); return this.handleAuthData(authData);
@@ -520,7 +519,7 @@ RestWrite.prototype.ensureUniqueAuthDataId = async function () {
}; };
RestWrite.prototype.handleAuthData = async function (authData) { RestWrite.prototype.handleAuthData = async function (authData) {
const r = await Auth.findUsersWithAuthData(this.config, authData); const r = await Auth.findUsersWithAuthData(this.config, authData, true);
const results = this.filteredObjectsByACL(r); const results = this.filteredObjectsByACL(r);
const userId = this.getUserId(); const userId = this.getUserId();

View File

@@ -69,6 +69,17 @@ class CheckGroupServerConfig extends CheckGroup {
} }
}, },
}), }),
new Check({
title: 'Insecure auth adapters disabled',
warning:
"Attackers may explore insecure auth adapters' vulnerabilities and log in on behalf of another user.",
solution: "Change Parse Server configuration to 'enableInsecureAuthAdapters: false'.",
check: () => {
if (config.enableInsecureAuthAdapters !== false) {
throw 1;
}
},
}),
]; ];
} }
} }

View File

@@ -32,6 +32,7 @@ runner({
help, help,
usage: '[options] <path/to/configuration.json>', usage: '[options] <path/to/configuration.json>',
start: function (program, options, logOptions) { start: function (program, options, logOptions) {
if (!options.appId || !options.masterKey) { if (!options.appId || !options.masterKey) {
program.outputHelp(); program.outputHelp();
console.error(''); console.error('');