From 466826fe05f319a145af16895a27aa5ddb5b50b5 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Wed, 12 Jun 2024 18:24:45 +0200 Subject: [PATCH] implement facebook app secret proof closes #5245 note that I couldn't get `appsecret_time` working, but it seems to be working without --- .../companion/src/server/provider/Provider.js | 5 +- .../src/server/provider/facebook/index.js | 84 ++++++++++++++----- .../companion/src/server/provider/index.js | 5 +- .../companion/test/__tests__/providers.js | 5 +- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/packages/@uppy/companion/src/server/provider/Provider.js b/packages/@uppy/companion/src/server/provider/Provider.js index 43d3a978e9..cfbcfbd973 100644 --- a/packages/@uppy/companion/src/server/provider/Provider.js +++ b/packages/@uppy/companion/src/server/provider/Provider.js @@ -6,13 +6,14 @@ const { MAX_AGE_24H } = require('../helpers/jwt') class Provider { /** * - * @param {{providerName: string, allowLocalUrls: boolean, providerGrantConfig?: object}} options + * @param {{providerName: string, allowLocalUrls: boolean, providerGrantConfig?: object, secret: string}} options */ - constructor ({ allowLocalUrls, providerGrantConfig }) { + constructor ({ allowLocalUrls, providerGrantConfig, secret }) { // Some providers might need cookie auth for the thumbnails fetched via companion this.needsCookieAuth = false this.allowLocalUrls = allowLocalUrls this.providerGrantConfig = providerGrantConfig + this.secret = secret return this } diff --git a/packages/@uppy/companion/src/server/provider/facebook/index.js b/packages/@uppy/companion/src/server/provider/facebook/index.js index b48a24349b..532aab6a57 100644 --- a/packages/@uppy/companion/src/server/provider/facebook/index.js +++ b/packages/@uppy/companion/src/server/provider/facebook/index.js @@ -1,24 +1,15 @@ +const crypto = require('node:crypto'); + const Provider = require('../Provider') const { getURLMeta } = require('../../helpers/request') const logger = require('../../logger') const { adaptData, sortImages } = require('./adapter') const { withProviderErrorHandling } = require('../providerErrors') const { prepareStream } = require('../../helpers/utils') +const { StreamHttpJsonError } = require('../../helpers/utils') const got = require('../../got') -const getClient = async ({ token }) => (await got).extend({ - prefixUrl: 'https://graph.facebook.com', - headers: { - authorization: `Bearer ${token}`, - }, -}) - -async function getMediaUrl ({ token, id }) { - const body = await (await getClient({ token })).get(String(id), { searchParams: { fields: 'images' }, responseType: 'json' }).json() - const sortedImages = sortImages(body.images) - return sortedImages[sortedImages.length - 1].source -} /** * Adapter for API https://developers.facebook.com/docs/graph-api/using-graph-api/ @@ -28,6 +19,49 @@ class Facebook extends Provider { return 'facebook' } + async runRequestBatch({ token, requests }) { + // https://developers.facebook.com/docs/facebook-login/security/#appsecret + // couldn't get `appsecret_time` working, but it seems to be working without it + // const time = Math.floor(Date.now() / 1000) + const appSecretProof = crypto.createHmac('sha256', this.secret) + // .update(`${token}|${time}`) + .update(token) + .digest('hex'); + + const form = new FormData() + form.append('access_token', token) + form.append('appsecret_proof', appSecretProof) + // form.append('appsecret_time', String(time)) + form.append('batch', JSON.stringify(requests)) + + const responsesRaw = await (await got).post('https://graph.facebook.com', { + // @ts-expect-error todo types + body: form, + }).json() + + const responses = responsesRaw.map((response) => ({ ...response, body: JSON.parse(response.body) })) + + responses.forEach((response) => { + if (response.code !== 200) { + throw new StreamHttpJsonError({ statusCode: response.code, responseJson: response.body }) + } + }) + + return responses + } + + async getMediaUrl ({ token, id }) { + const [{ body }] = await this.runRequestBatch({ + token, + requests: [ + { method: 'GET', relative_url: `${id}?${new URLSearchParams({ fields: 'images' }).toString()}` }, + ], + }); + + const sortedImages = sortImages(body.images) + return sortedImages[sortedImages.length - 1].source + } + async list ({ directory, token, query = { cursor: null } }) { return this.#withErrorHandling('provider.facebook.list.error', async () => { const qs = { fields: 'name,cover_photo,created_time,type' } @@ -40,19 +74,23 @@ class Facebook extends Provider { qs.fields = 'icon,images,name,width,height,created_time' } - const client = await getClient({ token }) + const [response1, response2] = await this.runRequestBatch({ + token, + requests: [ + { method: 'GET', relative_url: `me?${new URLSearchParams({ fields: 'email' }).toString()}` }, + { method: 'GET', relative_url: `${path}?${new URLSearchParams(qs)}` }, + ], + }); - const [{ email }, list] = await Promise.all([ - client.get('me', { searchParams: { fields: 'email' }, responseType: 'json' }).json(), - client.get(path, { searchParams: qs, responseType: 'json' }).json(), - ]) + const { email } = response1.body + const list = response2.body return adaptData(list, email, directory, query) }) } async download ({ id, token }) { return this.#withErrorHandling('provider.facebook.download.error', async () => { - const url = await getMediaUrl({ token, id }) + const url = await this.getMediaUrl({ token, id }) const stream = (await got).stream.get(url, { responseType: 'json' }) await prepareStream(stream) return { stream } @@ -68,7 +106,7 @@ class Facebook extends Provider { async size ({ id, token }) { return this.#withErrorHandling('provider.facebook.size.error', async () => { - const url = await getMediaUrl({ token, id }) + const url = await this.getMediaUrl({ token, id }) const { size } = await getURLMeta(url) return size }) @@ -76,7 +114,13 @@ class Facebook extends Provider { async logout ({ token }) { return this.#withErrorHandling('provider.facebook.logout.error', async () => { - await (await getClient({ token })).delete('me/permissions', { responseType: 'json' }).json() + await this.runRequestBatch({ + token, + requests: [ + { method: 'DELETE', relative_url: 'me/permissions' }, + ], + }); + return { revoked: true } }) } diff --git a/packages/@uppy/companion/src/server/provider/index.js b/packages/@uppy/companion/src/server/provider/index.js index c556395b30..6c87f2044b 100644 --- a/packages/@uppy/companion/src/server/provider/index.js +++ b/packages/@uppy/companion/src/server/provider/index.js @@ -41,7 +41,7 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => { const middleware = (req, res, next, providerName) => { const ProviderClass = providers[providerName] if (ProviderClass && validOptions(req.companion.options)) { - const { allowLocalUrls } = req.companion.options + const { allowLocalUrls, providerOptions } = req.companion.options const { oauthProvider } = ProviderClass let providerGrantConfig @@ -51,7 +51,8 @@ module.exports.getProviderMiddleware = (providers, grantConfig) => { req.companion.providerGrantConfig = providerGrantConfig } - req.companion.provider = new ProviderClass({ providerName, providerGrantConfig, allowLocalUrls }) + const { secret } = providerOptions[providerName] + req.companion.provider = new ProviderClass({ secret, providerName, providerGrantConfig, allowLocalUrls }) req.companion.providerClass = ProviderClass } else { logger.warn('invalid provider options detected. Provider will not be loaded', 'provider.middleware.invalid', req.id) diff --git a/packages/@uppy/companion/test/__tests__/providers.js b/packages/@uppy/companion/test/__tests__/providers.js index 6012490549..5f69c5e676 100644 --- a/packages/@uppy/companion/test/__tests__/providers.js +++ b/packages/@uppy/companion/test/__tests__/providers.js @@ -19,8 +19,9 @@ const defaults = require('../fixtures/constants') const tokenService = require('../../src/server/helpers/jwt') const { getServer } = require('../mockserver') +const secret = process.env.COMPANION_SECRET // todo don't share server between tests. rewrite to not use env variables -const authServer = getServer({ COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT: '0' }) +const authServer = getServer({ COMPANION_CLIENT_SOCKET_CONNECT_TIMEOUT: '0', COMPANION_SECRET: secret }) const OAUTH_STATE = 'some-cool-nice-encrytpion' const providers = require('../../src/server/provider').getDefaultProviders() @@ -34,7 +35,7 @@ const authData = {} providerNames.forEach((provider) => { authData[provider] = { accessToken: 'token value' } }) -const token = tokenService.generateEncryptedAuthToken(authData, process.env.COMPANION_SECRET) +const token = tokenService.generateEncryptedAuthToken(authData, secret) const thisOrThat = (value1, value2) => { if (value1 !== undefined) {