Skip to content

Commit

Permalink
@uppy/companion: implement facebook app secret proof (#5249)
Browse files Browse the repository at this point in the history
note that I couldn't get `appsecret_time` working, but it seems to be working without
  • Loading branch information
mifi authored Jun 27, 2024
1 parent d25cbde commit 3310c12
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 58 deletions.
1 change: 1 addition & 0 deletions docs/guides/migration-guides.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ These cover all the major Uppy versions and how to migrate to them.
- `getProtectedHttpAgent` parameter `blockLocalIPs` changed to `allowLocalIPs`
(inverted boolean).
- `downloadURL` 2nd (boolean) argument inverted.
- `StreamHttpJsonError` renamed to `HttpError`.

### `@uppy/companion-client`

Expand Down
10 changes: 5 additions & 5 deletions packages/@uppy/companion/src/server/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ module.exports.decrypt = (encrypted, secret) => {

module.exports.defaultGetKey = ({ filename }) => `${crypto.randomUUID()}-${filename}`

class StreamHttpJsonError extends Error {
class HttpError extends Error {
statusCode

responseJson
Expand All @@ -157,11 +157,11 @@ class StreamHttpJsonError extends Error {
super(`Request failed with status ${statusCode}`)
this.statusCode = statusCode
this.responseJson = responseJson
this.name = 'StreamHttpJsonError'
this.name = 'HttpError'
}
}

module.exports.StreamHttpJsonError = StreamHttpJsonError
module.exports.HttpError = HttpError

module.exports.prepareStream = async (stream) => new Promise((resolve, reject) => {
stream
Expand All @@ -176,7 +176,7 @@ module.exports.prepareStream = async (stream) => new Promise((resolve, reject) =
})
.on('error', (err) => {
// In this case the error object is not a normal GOT HTTPError where json is already parsed,
// we create our own StreamHttpJsonError error for this case
// we create our own HttpError error for this case
if (typeof err.response?.body === 'string' && typeof err.response?.statusCode === 'number') {
let responseJson
try {
Expand All @@ -186,7 +186,7 @@ module.exports.prepareStream = async (stream) => new Promise((resolve, reject) =
return
}

reject(new StreamHttpJsonError({ statusCode: err.response.statusCode, responseJson }))
reject(new HttpError({ statusCode: err.response.statusCode, responseJson }))
return
}

Expand Down
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
79 changes: 62 additions & 17 deletions packages/@uppy/companion/src/server/provider/facebook/index.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,57 @@
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 { HttpError } = 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()
async function runRequestBatch({ secret, 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', secret)
// .update(`${token}|${time}`)
.update(token)
.digest('hex');

const form = {
access_token: token,
appsecret_proof: appSecretProof,
// appsecret_time: String(time),
batch: JSON.stringify(requests),
}

const responsesRaw = await (await got).post('https://graph.facebook.com', { form }).json()

const responses = responsesRaw.map((response) => ({ ...response, body: JSON.parse(response.body) }))

const errorResponse = responses.find((response) => response.code !== 200)
if (errorResponse) {
throw new HttpError({ statusCode: errorResponse.code, responseJson: errorResponse.body })
}

return responses
}

async function getMediaUrl ({ secret, token, id }) {
const [{ body }] = await runRequestBatch({
secret,
token,
requests: [
{ method: 'GET', relative_url: `${id}?${new URLSearchParams({ fields: 'images' }).toString()}` },
],
});

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 @@ -40,19 +73,24 @@ class Facebook extends Provider {
qs.fields = 'icon,images,name,width,height,created_time'
}

const client = await getClient({ token })
const [response1, response2] = await runRequestBatch({
secret: this.secret,
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 getMediaUrl({ secret: this.secret, token, id })
const stream = (await got).stream.get(url, { responseType: 'json' })
await prepareStream(stream)
return { stream }
Expand All @@ -68,15 +106,22 @@ 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 getMediaUrl({ secret: this.secret, 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 runRequestBatch({
secret: this.secret,
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 @@ -42,7 +42,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 @@ -52,7 +52,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
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function withProviderErrorHandling({
if (err?.name === 'HTTPError') {
statusCode = err.response?.statusCode
body = err.response?.body
} else if (err?.name === 'StreamHttpJsonError') {
} else if (err?.name === 'HttpError') {
statusCode = err.statusCode
body = err.responseJson
}
Expand Down
100 changes: 69 additions & 31 deletions packages/@uppy/companion/test/__tests__/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,29 +234,44 @@ describe('list provider files', () => {
})

test('facebook', async () => {
nock('https://graph.facebook.com').get('/me?fields=email').reply(200, {
name: 'Fiona Fox',
birthday: '01/01/1985',
email: defaults.USERNAME,
})
nock('https://graph.facebook.com').get('/ALBUM-ID/photos?fields=icon%2Cimages%2Cname%2Cwidth%2Cheight%2Ccreated_time').reply(200, {
data: [
{
images: [
nock('https://graph.facebook.com').post('/',
[
'access_token=token+value',
'appsecret_proof=ee28d8152093b877f193f5fe84a34544ec27160e7f34c7645d02930b3fa95160',
`batch=${encodeURIComponent('[{"method":"GET","relative_url":"me?fields=email"},{"method":"GET","relative_url":"ALBUM-ID/photos?fields=icon%2Cimages%2Cname%2Cwidth%2Cheight%2Ccreated_time"}]')}`,
].join('&')
).reply(200,
[
{
code: 200,
body: JSON.stringify({
name: 'Fiona Fox',
birthday: '01/01/1985',
email: defaults.USERNAME,
}),
},
{
code: 200,
body: JSON.stringify({
data: [
{
height: 1365,
source: defaults.THUMBNAIL_URL,
width: 2048,
images: [
{
height: 1365,
source: defaults.THUMBNAIL_URL,
width: 2048,
},
],
width: 720,
height: 479,
created_time: '2015-07-17T17:26:50+0000',
id: defaults.ITEM_ID,
},
],
width: 720,
height: 479,
created_time: '2015-07-17T17:26:50+0000',
id: defaults.ITEM_ID,
},
],
paging: {},
})
paging: {},
}),
},
])

const { username, items, providerFixture } = await runTest('facebook')
expect1({ username, items, providerFixture })
Expand Down Expand Up @@ -396,16 +411,27 @@ describe('provider file gets downloaded from', () => {

test('facebook', async () => {
// times(2) because of size request
nock('https://graph.facebook.com').get(`/${defaults.ITEM_ID}?fields=images`).times(2).reply(200, {
images: [
{
height: 1365,
source: defaults.THUMBNAIL_URL,
width: 2048,
},
],
id: defaults.ITEM_ID,
})
nock('https://graph.facebook.com').post('/',
[
'access_token=token+value',
'appsecret_proof=ee28d8152093b877f193f5fe84a34544ec27160e7f34c7645d02930b3fa95160',
`batch=${encodeURIComponent('[{"method":"GET","relative_url":"DUMMY-FILE-ID?fields=images"}]')}`,
].join('&')
).times(2).reply(200,
[{
code: 200,
body: JSON.stringify({
images: [
{
height: 1365,
source: defaults.THUMBNAIL_URL,
width: 2048,
},
],
id: defaults.ITEM_ID,
}),
}])

await runTest('facebook')
})

Expand Down Expand Up @@ -492,7 +518,19 @@ describe('logout of provider', () => {
})

test('facebook', async () => {
nock('https://graph.facebook.com').delete('/me/permissions').reply(200, {})
// times(2) because of size request
nock('https://graph.facebook.com').post('/',
[
'access_token=token+value',
'appsecret_proof=ee28d8152093b877f193f5fe84a34544ec27160e7f34c7645d02930b3fa95160',
`batch=${encodeURIComponent('[{"method":"DELETE","relative_url":"me/permissions"}]')}`,
].join('&')
).reply(200,
[{
code: 200,
body: JSON.stringify({}),
}])

await runTest('facebook')
})

Expand Down
3 changes: 3 additions & 0 deletions packages/@uppy/companion/test/mockserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const defaultEnv = {
COMPANION_INSTAGRAM_KEY: 'instagram_key',
COMPANION_INSTAGRAM_SECRET: 'instagram_secret',

COMPANION_FACEBOOK_KEY: 'facebook_key',
COMPANION_FACEBOOK_SECRET: 'facebook_secret',

COMPANION_ZOOM_KEY: localZoomKey,
COMPANION_ZOOM_SECRET: localZoomSecret,
COMPANION_ZOOM_VERIFICATION_TOKEN: localZoomVerificationToken,
Expand Down

0 comments on commit 3310c12

Please sign in to comment.