From 30d53ab9912ff72c9887aa50be6ecd26aebdc171 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 7 Mar 2023 11:50:32 +1100 Subject: [PATCH] feat: improve facebook adapter --- spec/AuthenticationAdapters.spec.js | 68 +++----- spec/ParseUser.spec.js | 13 ++ src/Adapters/Auth/facebook.js | 239 ++++++++++++++-------------- src/Adapters/Auth/index.js | 28 +++- src/Auth.js | 14 +- src/Config.js | 5 + 6 files changed, 196 insertions(+), 171 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index bb89596cef..615c46feba 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -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({}, {}); @@ -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 = { @@ -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 () => { @@ -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(() => { @@ -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'); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 4d3beaf349..80de81daf7 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -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); diff --git a/src/Adapters/Auth/facebook.js b/src/Adapters/Auth/facebook.js index 737657c8bd..d722001450 100644 --- a/src/Adapters/Auth/facebook.js +++ b/src/Adapters/Auth/facebook.js @@ -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(); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 2defcb0dc0..8d5a70286a 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -5,7 +5,7 @@ import AuthAdapter from './AuthAdapter'; const apple = require('./apple'); const gcenter = require('./gcenter'); const gpgames = require('./gpgames'); -const facebook = require('./facebook'); +import facebook from './facebook'; const instagram = require('./instagram'); const linkedin = require('./linkedin'); const meetup = require('./meetup'); @@ -135,7 +135,7 @@ function authDataValidator(provider, adapter, appIds, options) { }; } -function loadAuthAdapter(provider, authOptions) { +function loadAuthAdapter(provider, authOptions, validate) { // providers are auth providers implemented by default let defaultAdapter = providers[provider]; // authOptions can contain complete custom auth adapters or @@ -154,7 +154,7 @@ function loadAuthAdapter(provider, authOptions) { return; } - const adapter = + let adapter = defaultAdapter instanceof AuthAdapter ? defaultAdapter : Object.assign({}, defaultAdapter); const keys = [ 'validateAuthData', @@ -182,6 +182,10 @@ function loadAuthAdapter(provider, authOptions) { // Try the configuration methods if (providerOptions) { + adapter = + defaultAdapter instanceof AuthAdapter + ? new defaultAdapter.constructor() + : Object.assign({}, defaultAdapter); const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { keys.forEach(key => { @@ -191,13 +195,26 @@ function loadAuthAdapter(provider, authOptions) { }); } } - if (adapter.validateOptions) { - adapter.validateOptions(providerOptions); + + const isOverriden = keys.some(key => providerOptions?.[key]); + if (adapter.validateOptions && !isOverriden) { + try { + adapter.validateOptions(providerOptions); + } catch (e) { + adapter.enabled = false; + if (validate) { + throw e; + } + } } return { adapter, appIds, providerOptions }; } +function validateAuthConfig(auth) { + Object.keys(auth).map(key => loadAuthAdapter(key, auth, true)); +} + module.exports = function (authOptions = {}, enableAnonymousUsers = true) { let _enableAnonymousUsers = enableAnonymousUsers; const setEnableAnonymousUsers = function (enable) { @@ -252,3 +269,4 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) { }; module.exports.loadAuthAdapter = loadAuthAdapter; +module.exports.validateAuthConfig = validateAuthConfig; diff --git a/src/Auth.js b/src/Auth.js index abd14391db..c41af7fbbc 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -441,20 +441,20 @@ const handleAuthDataValidation = async (authData, req, foundUser) => { acc.authData[provider] = null; continue; } - const { validator } = req.config.authDataManager.getValidatorForProvider(provider); + const { validator, adapter } = req.config.authDataManager.getValidatorForProvider(provider); const authProvider = (req.config.auth || {})[provider] || {}; + if (!validator || authProvider.enabled === false || adapter.enabled === false) { + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); + } if (authProvider.enabled == null) { Deprecator.logRuntimeDeprecation({ usage: `Using the authentication adapter "${provider}" without explicitly enabling it`, solution: `Enable the authentication adapter by setting the Parse Server option "auth.${provider}.enabled: true".`, }); } - if (!validator || authProvider.enabled === false) { - throw new Parse.Error( - Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.' - ); - } let validationResult = await validator(authData[provider], req, user, requestObject); method = validationResult && validationResult.method; requestObject.triggerName = method; diff --git a/src/Config.js b/src/Config.js index 2e7ef389c7..0311e568ed 100644 --- a/src/Config.js +++ b/src/Config.js @@ -7,6 +7,7 @@ import net from 'net'; import AppCache from './cache'; import DatabaseController from './Controllers/DatabaseController'; import { logLevels as validLogLevels } from './Controllers/LoggerController'; +import AuthAdapter from './Adapters/Auth'; import { AccountLockoutOptions, DatabaseOptions, @@ -86,6 +87,7 @@ export class Config { logLevels, rateLimit, databaseOptions, + auth, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -124,6 +126,9 @@ export class Config { this.validateRateLimit(rateLimit); this.validateLogLevels(logLevels); this.validateDatabaseOptions(databaseOptions); + if (auth) { + AuthAdapter.validateAuthConfig(auth); + } } static validateControllers({