Skip to content

Instantly share code, notes, and snippets.

@fjahn
Last active November 23, 2023 07:15
Show Gist options
  • Save fjahn/112ecdd690ba72340deb17169554f016 to your computer and use it in GitHub Desktop.
Save fjahn/112ecdd690ba72340deb17169554f016 to your computer and use it in GitHub Desktop.
Facebook Data Deletion Callback

Implementation for the Data Deletion Callback in JavaScript for Node with Expressjs.

JSON formatting

The most stupid pitfall for this callback is that the response has to look like this:

{ url: '<url>', confirmation_code: '<code>' }

This and other more reasonable variations are invalid:

{ "url": "<url>", "confirmation_code": "<code>" }

This means you cannot use JSON.stringify or Response.json(object), but this glorious snippet:

res.type('json')
res.send(`{ url: '${url}', confirmation_code: '${confirmationCode}' }`)

Signature validation

Another pitfall is that Facebook's signature always has a = appended (see line 43 in parseSignedRequest.mjs).

import crypto from 'crypto'
import parseSignedRequest from './parseSignedRequest.mjs'
import { instance as fileStorage } from '../ConfirmingFileStorage.mjs'
import { getLogger } from '../logger.mjs'
import { toAbsoluteUrl } from '../util.mjs'
const logger = getLogger('deletionEndpoint')
export function registerHandles (app) {
app.post('/facebook/deletion', [parseSignedRequest, async (req, res) => {
console.log('Received facebook deletion request')
const userId = req.body['user_id']
// Remove all data for user here
const confirmationCode = getConfirmationCode()
const path = `/facebook/deletion-status?code=${confirmationCode}`
const url = toAbsoluteUrl(req, path)
// Facebook requires the JSON to be non-quoted and formatted like this, so we need to create the JSON by hand:
res.type('json')
res.send(`{ url: '${url}', confirmation_code: '${confirmationCode}' }`)
}])
app.get('/facebook/deletion-status', (req, res) => {
const code = req.query.code
res.render('facebook/deletionStatus', { code })
})
}
function getConfirmationCode () {
return crypto.randomBytes(10).toString('hex')
}
import * as base64url from 'base64-url'
import crypto from 'crypto'
import config from 'config'
import { getLogger } from '../logger.mjs'
import { getOrThrowClientError } from '../util.mjs'
import ResponseError from '../ResponseError.mjs'
const logger = getLogger('parseSignedRequest')
export default function parseSignedRequest (req, res, next) {
const signedRequest = getSignedRequest(req)
logger.debug('Validating signature', {
signedRequest
})
const [signature, payload] = getSignatureAndPayloadFromSignedRequest(signedRequest)
validateSignature(signature, payload)
replaceBodyWithDecodedPayload(req, payload)
next()
}
function getSignedRequest (req) {
return getOrThrowClientError(req, 'body', 'signed_request')
}
function getSignatureAndPayloadFromSignedRequest (signedRequest) {
const [encodedSignature, payload] = signedRequest.split('.', 2)
if (encodedSignature == null || payload == null) {
throw new ResponseError(400, 'Signed request has invalid format')
}
const signature = decodeSignature(encodedSignature)
return [signature, payload]
}
function replaceBodyWithDecodedPayload (req, payload) {
const body = decodePayload(payload)
req.body = body
}
function validateSignature (actualSignature, payload) {
const expectedSignature = getExpectedSignature(payload)
// For some reason, the actual signature always has a '=' appended
const actualSignatureWithEqualsSign = actualSignature + '='
if (actualSignatureWithEqualsSign !== expectedSignature) {
throw new ResponseError(401, 'Invalid signature')
}
}
function decodeSignature (encodedSignature) {
return urlDecode(encodedSignature)
}
function decodePayload (payload) {
const bodyJson = base64url.decode(urlDecode(payload))
try {
return JSON.parse(bodyJson)
} catch (error) {
// If the JSON is invalid, this is the client's fault
error.code = 400
throw error
}
}
function urlDecode (string) {
return string.replace(/-/g, '+').replace(/_/g, '/')
}
function getExpectedSignature (payload) {
const secret = config.get('services.facebook.appSecret')
const hmac = crypto.createHmac('sha256', secret)
hmac.update(payload)
return hmac.digest('base64')
}
@fjahn
Copy link
Author

fjahn commented Dec 31, 2021

I left those out intentionally because the gist is just supposed to show how the data deletion request works.

If you implement this yourself, you can just leave out the calls to the log functions.

If you want to know how to get the full URI of a request, check out this Stackoverflow post.

ResponseError is simply a plain JavaScript error with a status property, and causes Node to respond with the corresponding HTTP error code.

@learntheropes
Copy link

Can you please clarify what this the scope of this function?
function getSignedRequest (req) { return getOrThrowClientError(req, 'body', 'signed_request') }

@fjahn
Copy link
Author

fjahn commented Jan 3, 2022

Basically, it tries to find signed_request within the body and returns it. If the key is missing, clients get the response code 400. Here is the implementation:

/**
 * @param req {Request}
 * @param type {'query'|'body'}
 * @param key {string}
 * @returns {*}
 */
export function getOrThrowClientError (req, type, key) {
  const getError = () => {
    return new ResponseError(400, `Missing ${type} parameter '${key}'`)
  }
  let array
  switch (type) {
    case 'query':
      array = req.query
      break
    case 'body':
      array = req.body
      break
    default:
      throw new Error(`Unknown type: ${type}`)
  }
  if (!(key in array)) {
    throw getError()
  }
  const value = array[key]
  if (value == null) {
    throw getError()
  }
  return value
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment