Skip to content

Commit

Permalink
implement facebook app secret proof
Browse files Browse the repository at this point in the history
closes #5245

note that I couldn't get `appsecret_time` working, but it seems to be working without
  • Loading branch information
mifi committed Jun 12, 2024
1 parent 181ea6d commit 466826f
Show file tree
Hide file tree
Showing 4 changed files with 73 additions and 26 deletions.
5 changes: 3 additions & 2 deletions packages/@uppy/companion/src/server/provider/Provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
84 changes: 64 additions & 20 deletions packages/@uppy/companion/src/server/provider/facebook/index.js
Original file line number Diff line number Diff line change
@@ -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/
Expand All @@ -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' }
Expand All @@ -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 }
Expand All @@ -68,15 +106,21 @@ 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
})
}

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 }
})
}
Expand Down
5 changes: 3 additions & 2 deletions packages/@uppy/companion/src/server/provider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions packages/@uppy/companion/test/__tests__/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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) {
Expand Down

0 comments on commit 466826f

Please sign in to comment.