diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e0421c50..395ac39f82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ___ - NEW: LiveQuery support for $and, $nor, $containedBy, $geoWithin, $geoIntersects queries [#7113](https://github.com/parse-community/parse-server/pull/7113). Thanks to [dplewis](https://github.com/dplewis) - NEW: Supporting patterns in LiveQuery server's config parameter `classNames` [#7131](https://github.com/parse-community/parse-server/pull/7131). Thanks to [Nes-si](https://github.com/Nes-si) - NEW: `requireAnyUserRoles` and `requireAllUserRoles` for Parse Cloud validator. [#7097](https://github.com/parse-community/parse-server/pull/7097). Thanks to [dblythy](https://github.com/dblythy) +- NEW: Support Facebook Limited Login [#7219](https://github.com/parse-community/parse-server/pull/7219). Thanks to [miguel-s](https://github.com/miguel-s) - IMPROVE: Retry transactions on MongoDB when it fails due to transient error [#7187](https://github.com/parse-community/parse-server/pull/7187). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo). - IMPROVE: Bump tests to use Mongo 4.4.4 [#7184](https://github.com/parse-community/parse-server/pull/7184). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo). - IMPROVE: Added new account lockout policy option `accountLockout.unlockOnPasswordReset` to automatically unlock account on password reset. [#7146](https://github.com/parse-community/parse-server/pull/7146). Thanks to [Manuel Trezza](https://github.com/mtrezza). diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index ddeafa3668..9c6cfc6351 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -1756,3 +1756,387 @@ describe('microsoft graph auth adapter', () => { }); }); }); + +describe('facebook limited auth adapter', () => { + const facebook = require('../lib/Adapters/Auth/facebook'); + const jwt = require('jsonwebtoken'); + const util = require('util'); + + // TODO: figure out a way to run this test alongside facebook classic tests + xit('(using client id as string) should throw error with missing id_token', async () => { + try { + await facebook.validateAuthData({}, { clientId: 'secret' }); + fail(); + } catch (e) { + expect(e.message).toBe('Facebook auth is not configured.'); + } + }); + + // TODO: figure out a way to run this test alongside facebook classic tests + xit('(using client id as array) should throw error with missing id_token', async () => { + try { + await facebook.validateAuthData({}, { clientId: ['secret'] }); + fail(); + } catch (e) { + expect(e.message).toBe('Facebook auth is not configured.'); + } + }); + + it('should not decode invalid id_token', async () => { + try { + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } + }); + + it('should throw error if public key used to encode token is not available', async () => { + const fakeDecodedToken = { + header: { kid: '789', alg: 'RS256' }, + }; + try { + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + `Unable to find matching key for Key ID: ${fakeDecodedToken.header.kid}` + ); + } + }); + + it('should use algorithm from key header to verify id_token', async () => { + const fakeClaim = { + iss: 'https://facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { + header: { kid: '123', alg: 'RS256' }, + }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + const fakeGetSigningKeyAsyncFunction = () => { + return { + kid: '123', + rsaPublicKey: 'the_rsa_public_key', + }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + + const result = await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(result).toEqual(fakeClaim); + expect(jwt.verify.calls.first().args[2].algorithms).toEqual(fakeDecodedToken.header.alg); + }); + + it('should not verify invalid id_token', async () => { + const fakeDecodedToken = { + header: { kid: '123', alg: 'RS256' }, + }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { + kid: '123', + rsaPublicKey: 'the_rsa_public_key', + }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + + try { + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt malformed'); + } + }); + + it('(using client id as array) should not verify invalid id_token', async () => { + try { + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('provided token does not decode as JWT'); + } + }); + + it('(using client id as string) should verify id_token', async () => { + const fakeClaim = { + iss: 'https://facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { + header: { kid: '123', alg: 'RS256' }, + }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { + kid: '123', + rsaPublicKey: 'the_rsa_public_key', + }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as array) should verify id_token', async () => { + const fakeClaim = { + iss: 'https://facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { + header: { kid: '123', alg: 'RS256' }, + }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { + kid: '123', + rsaPublicKey: 'the_rsa_public_key', + }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: ['secret'] } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as array with multiple items) should verify id_token', async () => { + const fakeClaim = { + iss: 'https://facebook.com', + aud: 'secret', + exp: Date.now(), + sub: 'the_user_id', + }; + const fakeDecodedToken = { + header: { kid: '123', alg: 'RS256' }, + }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { + kid: '123', + rsaPublicKey: 'the_rsa_public_key', + }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + const result = await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: ['secret', 'secret 123'] } + ); + expect(result).toEqual(fakeClaim); + }); + + it('(using client id as string) should throw error with with invalid jwt issuer', async () => { + const fakeClaim = { + iss: 'https://not.facebook.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { + header: { kid: '123', alg: 'RS256' }, + }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { + kid: '123', + rsaPublicKey: 'the_rsa_public_key', + }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct OpenID provider - expected: https://facebook.com | from: https://not.facebook.com' + ); + } + }); + + // TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account + // and a private key + xit('(using client id as array) should throw error with with invalid jwt issuer', async () => { + const fakeClaim = { + iss: 'https://not.facebook.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { + header: { kid: '123', alg: 'RS256' }, + }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { + kid: '123', + rsaPublicKey: 'the_rsa_public_key', + }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await facebook.validateAuthData( + { + id: 'INSERT ID HERE', + token: 'INSERT FACEBOOK TOKEN HERE WITH INVALID JWT ISSUER', + }, + { clientId: ['INSERT CLIENT ID HERE'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct OpenID provider - expected: https://facebook.com | from: https://not.facebook.com' + ); + } + }); + + it('(using client id as string) should throw error with with invalid jwt issuer', async () => { + const fakeClaim = { + iss: 'https://not.facebook.com', + sub: 'the_user_id', + }; + const fakeDecodedToken = { + header: { kid: '123', alg: 'RS256' }, + }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { + kid: '123', + rsaPublicKey: 'the_rsa_public_key', + }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await facebook.validateAuthData( + { + id: 'INSERT ID HERE', + token: 'INSERT FACEBOOK TOKEN HERE WITH INVALID JWT ISSUER', + }, + { clientId: 'INSERT CLIENT ID HERE' } + ); + fail(); + } catch (e) { + expect(e.message).toBe( + 'id token not issued by correct OpenID provider - expected: https://facebook.com | from: https://not.facebook.com' + ); + } + }); + + // TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account + // and a private key + xit('(using client id as string) should throw error with invalid jwt clientId', async () => { + try { + await facebook.validateAuthData( + { + id: 'INSERT ID HERE', + token: 'INSERT FACEBOOK TOKEN HERE', + }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt audience invalid. expected: secret'); + } + }); + + // TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account + // and a private key + xit('(using client id as array) should throw error with invalid jwt clientId', async () => { + try { + await facebook.validateAuthData( + { + id: 'INSERT ID HERE', + token: 'INSERT FACEBOOK TOKEN HERE', + }, + { clientId: ['secret'] } + ); + fail(); + } catch (e) { + expect(e.message).toBe('jwt audience invalid. expected: secret'); + } + }); + + // TODO: figure out a way to generate our own facebook signed tokens, perhaps with a parse facebook account + // and a private key + xit('should throw error with invalid user id', async () => { + try { + await facebook.validateAuthData( + { + id: 'invalid user', + token: 'INSERT FACEBOOK TOKEN HERE', + }, + { clientId: 'INSERT CLIENT ID HERE' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); + + it('should throw error with with invalid user id', async () => { + const fakeClaim = { + iss: 'https://facebook.com', + aud: 'invalid_client_id', + sub: 'a_different_user_id', + }; + const fakeDecodedToken = { + header: { kid: '123', alg: 'RS256' }, + }; + spyOn(jwt, 'decode').and.callFake(() => fakeDecodedToken); + const fakeGetSigningKeyAsyncFunction = () => { + return { + kid: '123', + rsaPublicKey: 'the_rsa_public_key', + }; + }; + spyOn(util, 'promisify').and.callFake(() => fakeGetSigningKeyAsyncFunction); + spyOn(jwt, 'verify').and.callFake(() => fakeClaim); + + try { + await facebook.validateAuthData( + { id: 'the_user_id', token: 'the_token' }, + { clientId: 'secret' } + ); + fail(); + } catch (e) { + expect(e.message).toBe('auth data is invalid for this user.'); + } + }); +}); diff --git a/src/Adapters/Auth/facebook.js b/src/Adapters/Auth/facebook.js index 3e3d79b3c3..95fba0b3e7 100644 --- a/src/Adapters/Auth/facebook.js +++ b/src/Adapters/Auth/facebook.js @@ -1,7 +1,12 @@ // Helper functions for accessing the Facebook Graph API. -const httpsRequest = require('./httpsRequest'); -var Parse = require('parse/node').Parse; +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 TOKEN_ISSUER = 'https://facebook.com'; function getAppSecretPath(authData, options = {}) { const appSecret = options.appSecret; @@ -16,8 +21,7 @@ function getAppSecretPath(authData, options = {}) { return `&appsecret_proof=${appsecret_proof}`; } -// Returns a promise that fulfills iff this user id is valid. -function validateAuthData(authData, options) { +function validateGraphToken(authData, options) { return graphRequest( 'me?fields=id&access_token=' + authData.access_token + getAppSecretPath(authData, options) ).then(data => { @@ -28,8 +32,7 @@ function validateAuthData(authData, options) { }); } -// Returns a promise that fulfills iff this app id is valid. -function validateAppId(appIds, authData, options) { +function validateGraphAppId(appIds, authData, options) { var access_token = authData.access_token; if (process.env.TESTING && access_token === 'test') { return Promise.resolve(); @@ -47,6 +50,95 @@ function validateAppId(appIds, authData, options) { }); } +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}` + ); + } + return key; +}; + +const getHeaderFromToken = token => { + const decodedToken = jwt.decode(token, { complete: true }); + if (!decodedToken) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'provided token does not decode as JWT'); + } + + return decodedToken.header; +}; + +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.'); + } + + const { kid: keyId, alg: algorithm } = getHeaderFromToken(token); + const ONE_HOUR_IN_MS = 3600000; + let jwtClaims; + + cacheMaxAge = cacheMaxAge || ONE_HOUR_IN_MS; + cacheMaxEntries = cacheMaxEntries || 5; + + const facebookKey = await getFacebookKeyByKeyId(keyId, cacheMaxEntries, cacheMaxAge); + const signingKey = facebookKey.publicKey || facebookKey.rsaPublicKey; + + 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; + + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, `${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}` + ); + } + + 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); + } +} + +// 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); + } +} + // A promisey wrapper for FB graph requests. function graphRequest(path) { return httpsRequest.get('https://graph.facebook.com/' + path);