Class: Facebook::SignedRequestVerifier

Inherits:
Object
  • Object
show all
Defined in:
app/services/facebook/signed_request_verifier.rb

Overview

Verifies and parses Meta's "signed_request" payload, used by both
Login (cookie-based flow) and the Data Deletion Callback webhook.

Meta's format is <base64url(signature)>.<base64url(json_payload)> where
the signature is HMAC-SHA256(encoded_payload, app_secret) — note the
HMAC is computed over the encoded payload string, not the decoded JSON.

Reference:
https://developers.facebook.com/docs/facebook-login/guides/advanced/oidc-token#signedrequest
https://developers.facebook.com/docs/development/create-an-app/app-dashboard/data-deletion-callback/

We can't reuse the parser bundled with omniauth-facebook
(OmniAuth::Facebook::SignedRequest.parse) directly because it raises on
failure — for a webhook controller we want a Result object we can branch
on without rescuing. The HMAC logic is otherwise identical.

Examples:

Happy path

result = Facebook::SignedRequestVerifier.call(
  signed_request: params[:signed_request],
  app_secret:     Heatwave::Configuration.fetch(:omniauth, :facebook_secret)
)
if result.valid?
  user_id = result.payload.fetch('user_id')
  # ...
end

Defined Under Namespace

Classes: Result

Constant Summary collapse

SUPPORTED_ALGORITHM =
'HMAC-SHA256'

Class Method Summary collapse

Class Method Details

.call(signed_request:, app_secret:) ⇒ Result

Parameters:

  • signed_request (String)

    the raw signed_request form param Meta posted

  • app_secret (String)

    the FB app's secret (omniauth.facebook_secret)

Returns:



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'app/services/facebook/signed_request_verifier.rb', line 42

def self.call(signed_request:, app_secret:)
  return Result.new(valid?: false, payload: nil, error: 'missing_signed_request') if signed_request.blank?
  return Result.new(valid?: false, payload: nil, error: 'missing_app_secret')     if app_secret.blank?

  encoded_sig, encoded_payload = signed_request.split('.', 2)
  return Result.new(valid?: false, payload: nil, error: 'malformed') if encoded_sig.blank? || encoded_payload.blank?

  payload = JSON.parse(base64_url_decode(encoded_payload))

  unless payload['algorithm'] == SUPPORTED_ALGORITHM
    return Result.new(valid?: false, payload: nil, error: "unsupported_algorithm:#{payload['algorithm']}")
  end

  expected_sig = OpenSSL::HMAC.digest('SHA256', app_secret, encoded_payload)
  provided_sig = base64_url_decode(encoded_sig)

  unless ActiveSupport::SecurityUtils.secure_compare(expected_sig, provided_sig)
    return Result.new(valid?: false, payload: nil, error: 'bad_signature')
  end

  Result.new(valid?: true, payload: payload, error: nil)
rescue JSON::ParserError => e
  Result.new(valid?: false, payload: nil, error: "json_parse:#{e.message}")
rescue ArgumentError => e
  Result.new(valid?: false, payload: nil, error: "base64_decode:#{e.message}")
end