Skip to content

feat: Improve facebook adapter #8462

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: alpha
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 26 additions & 42 deletions spec/AuthenticationAdapters.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ describe('AuthenticationProviders', function () {
'keycloak',
].map(function (providerName) {
it('Should validate structure of ' + providerName, done => {
const provider = require('../lib/Adapters/Auth/' + providerName);
let provider = require('../lib/Adapters/Auth/' + providerName);
if (provider.default) {
provider = provider.default;
}
jequal(typeof provider.validateAuthData, 'function');
jequal(typeof provider.validateAppId, 'function');
const validateAuthDataPromise = provider.validateAuthData({}, {});
Expand Down Expand Up @@ -82,7 +85,10 @@ describe('AuthenticationProviders', function () {
spyOn(require('../lib/Adapters/Auth/httpsRequest'), 'request').and.callFake(() => {
return Promise.resolve(responses[providerName] || { id: 'userId' });
});
const provider = require('../lib/Adapters/Auth/' + providerName);
let provider = require('../lib/Adapters/Auth/' + providerName);
if (provider.default) {
provider = provider.default;
}
let params = {};
if (providerName === 'vkontakte') {
params = {
Expand Down Expand Up @@ -469,27 +475,28 @@ describe('AuthenticationProviders', function () {
expect(providerOptions).toEqual(options.facebook);
});

it('should throw error when Facebook request appId is wrong data type', async () => {
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ id: 'a' });
});
const options = {
it('should throw error when Facebook config appId is wrong data type', async () => {
const auth = {
facebook: {
appIds: 'abcd',
appSecret: 'secret_sauce',
},
};
const authData = {
access_token: 'badtoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'facebook',
options
);
await expectAsync(adapter.validateAppId(appIds, authData, providerOptions)).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'appIds must be an array.')
);
await expectAsync(
reconfigureServer({
auth,
})
).toBeRejectedWith('facebook.appIds must be an array.');

await expectAsync(
reconfigureServer({
auth: {
facebook: {
appIds: [],
},
},
})
).toBeRejectedWith('facebook.appIds must have at least one appId.');
});

it('should handle Facebook appSecret for validating appIds', async () => {
Expand All @@ -514,29 +521,6 @@ describe('AuthenticationProviders', function () {
expect(httpsRequest.get.calls.first().args[0].includes('appsecret_proof')).toBe(true);
});

it('should throw error when Facebook request appId is wrong data type', async () => {
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
spyOn(httpsRequest, 'get').and.callFake(() => {
return Promise.resolve({ id: 'a' });
});
const options = {
facebook: {
appIds: 'abcd',
appSecret: 'secret_sauce',
},
};
const authData = {
access_token: 'badtoken',
};
const { adapter, appIds, providerOptions } = authenticationLoader.loadAuthAdapter(
'facebook',
options
);
await expectAsync(adapter.validateAppId(appIds, authData, providerOptions)).toBeRejectedWith(
new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'appIds must be an array.')
);
});

it('should handle Facebook appSecret for validating auth data', async () => {
const httpsRequest = require('../lib/Adapters/Auth/httpsRequest');
spyOn(httpsRequest, 'get').and.callFake(() => {
Expand Down Expand Up @@ -2023,7 +2007,7 @@ describe('microsoft graph auth adapter', () => {
});

describe('facebook limited auth adapter', () => {
const facebook = require('../lib/Adapters/Auth/facebook');
const facebook = require('../lib/Adapters/Auth/facebook').default;
const jwt = require('jsonwebtoken');
const util = require('util');
const authUtils = require('../lib/Adapters/Auth/utils');
Expand Down
13 changes: 13 additions & 0 deletions spec/ParseUser.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,19 @@ describe('Parse.User testing', () => {
);
});

it('cannot connect to unconfigured adapter', async () => {
await reconfigureServer({
auth: {},
});
const provider = getMockFacebookProvider();
Parse.User._registerAuthenticationProvider(provider);
const user = new Parse.User();
user.set('foo', 'bar');
await expectAsync(user._linkWith('facebook', {})).toBeRejectedWith(
new Parse.Error(Parse.Error.UNSUPPORTED_SERVICE, 'This authentication method is unsupported.')
);
});

it('should not call beforeLogin with become', async done => {
const provider = getMockFacebookProvider();
Parse.User._registerAuthenticationProvider(provider);
Expand Down
239 changes: 122 additions & 117 deletions src/Adapters/Auth/facebook.js
Original file line number Diff line number Diff line change
@@ -1,143 +1,148 @@
// Helper functions for accessing the Facebook Graph API.
const Parse = require('parse/node').Parse;
const crypto = require('crypto');
const jwksClient = require('jwks-rsa');
const util = require('util');
const jwt = require('jsonwebtoken');
const httpsRequest = require('./httpsRequest');
const authUtils = require('./utils');

const TOKEN_ISSUER = 'https://facebook.com';

function getAppSecretPath(authData, options = {}) {
const appSecret = options.appSecret;
if (!appSecret) {
return '';
import { Parse } from 'parse/node';
import crypto from 'crypto';
import jwksClient from 'jwks-rsa';
import util from 'util';
import jwt from 'jsonwebtoken';
import httpsRequest from './httpsRequest';
import authUtils from './utils';
import AuthAdapter from './AuthAdapter';

class FacebookAdapter extends AuthAdapter {
constructor() {
super();
this._TOKEN_ISSUER = 'https://facebook.com';
}
validateAuthData(authData, options) {
if (authData.token) {
return this.verifyIdToken(authData, options);
}
return this.validateGraphToken(authData);
}
const appsecret_proof = crypto
.createHmac('sha256', appSecret)
.update(authData.access_token)
.digest('hex');

return `&appsecret_proof=${appsecret_proof}`;
}

function validateGraphToken(authData, options) {
return graphRequest(
'me?fields=id&access_token=' + authData.access_token + getAppSecretPath(authData, options)
).then(data => {
if ((data && data.id == authData.id) || (process.env.TESTING && authData.id === 'test')) {
return;
validateAppId(_, authData) {
if (authData.token) {
return Promise.resolve();
}
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Facebook auth is invalid for this user.');
});
}
return this.validateGraphAppId(authData);
}

async function validateGraphAppId(appIds, authData, options) {
var access_token = authData.access_token;
if (process.env.TESTING && access_token === 'test') {
return;
validateOptions(opts) {
const appIds = opts?.appIds;
if (!Array.isArray(appIds)) {
throw 'facebook.appIds must be an array.';
}
if (!appIds.length) {
throw 'facebook.appIds must have at least one appId.';
}
this.appIds = appIds;
this.appSecret = opts?.appSecret;
}
if (!Array.isArray(appIds)) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'appIds must be an array.');

graphRequest(path) {
return httpsRequest.get(`https://graph.facebook.com/${path}`);
}
if (!appIds.length) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Facebook auth is not configured.');

getAppSecretPath(authData) {
const appSecret = this.appSecret;
if (!appSecret) {
return '';
}
const appsecret_proof = crypto
.createHmac('sha256', appSecret)
.update(authData.access_token)
.digest('hex');

return `&appsecret_proof=${appsecret_proof}`;
}
const data = await graphRequest(
`app?access_token=${access_token}${getAppSecretPath(authData, options)}`
);
if (!data || !appIds.includes(data.id)) {

async validateGraphToken(authData) {
const data = await this.graphRequest(
`me?fields=id&access_token=${authData.access_token}${this.getAppSecretPath(authData)}`
);
if (data?.id === authData.id || (process.env.TESTING && authData.id === 'test')) {
return;
}
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Facebook auth is invalid for this user.');
}
}

const getFacebookKeyByKeyId = async (keyId, cacheMaxEntries, cacheMaxAge) => {
const client = jwksClient({
jwksUri: `${TOKEN_ISSUER}/.well-known/oauth/openid/jwks/`,
cache: true,
cacheMaxEntries,
cacheMaxAge,
});

const asyncGetSigningKeyFunction = util.promisify(client.getSigningKey);

let key;
try {
key = await asyncGetSigningKeyFunction(keyId);
} catch (error) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
`Unable to find matching key for Key ID: ${keyId}`
async validateGraphAppId(authData) {
const access_token = authData.access_token;
if (process.env.TESTING && access_token === 'test') {
return;
}
const data = await this.graphRequest(
`app?access_token=${access_token}${this.getAppSecretPath(authData)}`
);
if (!data || !this.appIds.includes(data.id)) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
'Facebook auth is invalid for this user.'
);
}
}
return key;
};

const verifyIdToken = async ({ token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) => {
if (!token) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.');
async getFacebookKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge) {
const client = jwksClient({
jwksUri: `${this._TOKEN_ISSUER}/.well-known/oauth/openid/jwks/`,
cache: true,
cacheMaxEntries,
cacheMaxAge,
});

const asyncGetSigningKeyFunction = util.promisify(client.getSigningKey);

let key;
try {
key = await asyncGetSigningKeyFunction(keyId);
} catch (error) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
`Unable to find matching key for Key ID: ${keyId}`
);
}
return key;
}

const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
const ONE_HOUR_IN_MS = 3600000;
let jwtClaims;
async verifyIdToken({ token, id }, { clientId, cacheMaxEntries, cacheMaxAge }) {
if (!token) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'id token is invalid for this user.');
}

cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS;
cacheMaxEntries = cacheMaxEntries || 5;
const { kid: keyId, alg: algorithm } = authUtils.getHeaderFromToken(token);
const ONE_HOUR_IN_MS = 3600000;
let jwtClaims;

const facebookKey = await getFacebookKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge);
const signingKey = facebookKey.publicKey || facebookKey.rsaPublicKey;
cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS;
cacheMaxEntries = cacheMaxEntries || 5;

try {
jwtClaims = jwt.verify(token, signingKey, {
algorithms: algorithm,
// the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions.
audience: clientId,
});
} catch (exception) {
const message = exception.message;
const facebookKey = await this.getFacebookKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge);
const signingKey = facebookKey.publicKey || facebookKey.rsaPublicKey;

throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`);
}
try {
jwtClaims = jwt.verify(token, signingKey, {
algorithms: algorithm,
// the audience can be checked against a string, a regular expression or a list of strings and/or regular expressions.
audience: clientId,
});
} catch (exception) {
const message = exception.message;

if (jwtClaims.iss !== TOKEN_ISSUER) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
`id token not issued by correct OpenID provider - expected: ${TOKEN_ISSUER} | from: ${jwtClaims.iss}`
);
}
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${message}`);
}

if (jwtClaims.sub !== id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.');
}
return jwtClaims;
};

// Returns a promise that fulfills iff this user id is valid.
function validateAuthData(authData, options) {
if (authData.token) {
return verifyIdToken(authData, options);
} else {
return validateGraphToken(authData, options);
}
}
if (jwtClaims.iss !== this._TOKEN_ISSUER) {
throw new Parse.Error(
Parse.Error.OBJECT_NOT_FOUND,
`id token not issued by correct OpenID provider - expected: ${this._TOKEN_ISSUER} | from: ${jwtClaims.iss}`
);
}

// Returns a promise that fulfills iff this app id is valid.
function validateAppId(appIds, authData, options) {
if (authData.token) {
return Promise.resolve();
} else {
return validateGraphAppId(appIds, authData, options);
if (jwtClaims.sub !== id) {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'auth data is invalid for this user.');
}
return jwtClaims;
}
}

// A promisey wrapper for FB graph requests.
function graphRequest(path) {
return httpsRequest.get('https://graph.facebook.com/' + path);
}

module.exports = {
validateAppId: validateAppId,
validateAuthData: validateAuthData,
};
export default new FacebookAdapter();
Loading