mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-20 13:49:39 +02:00
fix: Plaid webhook verification (#1824)
* Fix Plaid webhook verification * Fix client creation in webhook controller
This commit is contained in:
parent
331de2f997
commit
5eb5ec7aef
5 changed files with 87 additions and 62 deletions
|
@ -53,7 +53,7 @@ module AccountableResource
|
||||||
private
|
private
|
||||||
def set_link_token
|
def set_link_token
|
||||||
@us_link_token = Current.family.get_link_token(
|
@us_link_token = Current.family.get_link_token(
|
||||||
webhooks_url: webhooks_url,
|
webhooks_url: plaid_us_webhooks_url,
|
||||||
redirect_url: accounts_url,
|
redirect_url: accounts_url,
|
||||||
accountable_type: accountable_type.name,
|
accountable_type: accountable_type.name,
|
||||||
region: :us
|
region: :us
|
||||||
|
@ -61,7 +61,7 @@ module AccountableResource
|
||||||
|
|
||||||
if Current.family.eu?
|
if Current.family.eu?
|
||||||
@eu_link_token = Current.family.get_link_token(
|
@eu_link_token = Current.family.get_link_token(
|
||||||
webhooks_url: webhooks_url,
|
webhooks_url: plaid_eu_webhooks_url,
|
||||||
redirect_url: accounts_url,
|
redirect_url: accounts_url,
|
||||||
accountable_type: accountable_type.name,
|
accountable_type: accountable_type.name,
|
||||||
region: :eu
|
region: :eu
|
||||||
|
@ -69,11 +69,16 @@ module AccountableResource
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def webhooks_url
|
def plaid_us_webhooks_url
|
||||||
return webhooks_plaid_url if Rails.env.production?
|
return webhooks_plaid_url if Rails.env.production?
|
||||||
|
|
||||||
base_url = ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/"))
|
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid"
|
||||||
base_url + "/webhooks/plaid"
|
end
|
||||||
|
|
||||||
|
def plaid_eu_webhooks_url
|
||||||
|
return webhooks_plaid_eu_url if Rails.env.production?
|
||||||
|
|
||||||
|
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu"
|
||||||
end
|
end
|
||||||
|
|
||||||
def accountable_type
|
def accountable_type
|
||||||
|
|
|
@ -6,8 +6,25 @@ class WebhooksController < ApplicationController
|
||||||
webhook_body = request.body.read
|
webhook_body = request.body.read
|
||||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||||
|
|
||||||
Provider::Plaid.validate_webhook!(plaid_verification_header, webhook_body)
|
client = Provider::Plaid.new(Rails.application.config.plaid, region: :us)
|
||||||
Provider::Plaid.process_webhook(webhook_body)
|
|
||||||
|
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||||
|
client.process_webhook(webhook_body)
|
||||||
|
|
||||||
|
render json: { received: true }, status: :ok
|
||||||
|
rescue => error
|
||||||
|
Sentry.capture_exception(error)
|
||||||
|
render json: { error: "Invalid webhook: #{error.message}" }, status: :bad_request
|
||||||
|
end
|
||||||
|
|
||||||
|
def plaid_eu
|
||||||
|
webhook_body = request.body.read
|
||||||
|
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||||
|
|
||||||
|
client = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu)
|
||||||
|
|
||||||
|
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||||
|
client.process_webhook(webhook_body)
|
||||||
|
|
||||||
render json: { received: true }, status: :ok
|
render json: { received: true }, status: :ok
|
||||||
rescue => error
|
rescue => error
|
||||||
|
|
|
@ -3,11 +3,11 @@ module Plaidable
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
def plaid_us_provider
|
def plaid_us_provider
|
||||||
Provider::Plaid.new(Rails.application.config.plaid, :us) if Rails.application.config.plaid
|
Provider::Plaid.new(Rails.application.config.plaid, region: :us) if Rails.application.config.plaid
|
||||||
end
|
end
|
||||||
|
|
||||||
def plaid_eu_provider
|
def plaid_eu_provider
|
||||||
Provider::Plaid.new(Rails.application.config.plaid_eu, :eu) if Rails.application.config.plaid_eu
|
Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu) if Rails.application.config.plaid_eu
|
||||||
end
|
end
|
||||||
|
|
||||||
def plaid_provider_for_region(region)
|
def plaid_provider_for_region(region)
|
||||||
|
|
|
@ -4,65 +4,67 @@ class Provider::Plaid
|
||||||
MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze
|
MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze
|
||||||
MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730
|
MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730
|
||||||
|
|
||||||
class << self
|
def initialize(config, region: :us)
|
||||||
def process_webhook(webhook_body)
|
|
||||||
parsed = JSON.parse(webhook_body)
|
|
||||||
type = parsed["webhook_type"]
|
|
||||||
code = parsed["webhook_code"]
|
|
||||||
|
|
||||||
item = PlaidItem.find_by(plaid_id: parsed["item_id"])
|
|
||||||
|
|
||||||
case [ type, code ]
|
|
||||||
when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ]
|
|
||||||
item.sync_later
|
|
||||||
when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ]
|
|
||||||
item.sync_later
|
|
||||||
when [ "HOLDINGS", "DEFAULT_UPDATE" ]
|
|
||||||
item.sync_later
|
|
||||||
else
|
|
||||||
Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_webhook!(verification_header, raw_body)
|
|
||||||
jwks_loader = ->(options) do
|
|
||||||
key_id = options[:kid]
|
|
||||||
|
|
||||||
jwk_response = client.webhook_verification_key_get(
|
|
||||||
Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ])
|
|
||||||
|
|
||||||
jwks.filter! { |key| key[:use] == "sig" }
|
|
||||||
jwks
|
|
||||||
end
|
|
||||||
|
|
||||||
payload, _header = JWT.decode(
|
|
||||||
verification_header, nil, true,
|
|
||||||
{
|
|
||||||
algorithms: [ "ES256" ],
|
|
||||||
jwks: jwks_loader,
|
|
||||||
verify_expiration: false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
issued_at = Time.at(payload["iat"])
|
|
||||||
raise JWT::VerificationError, "Webhook is too old" if Time.now - issued_at > 5.minutes
|
|
||||||
|
|
||||||
expected_hash = payload["request_body_sha256"]
|
|
||||||
actual_hash = Digest::SHA256.hexdigest(raw_body)
|
|
||||||
raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(config, region)
|
|
||||||
@client = Plaid::PlaidApi.new(
|
@client = Plaid::PlaidApi.new(
|
||||||
Plaid::ApiClient.new(config)
|
Plaid::ApiClient.new(config)
|
||||||
)
|
)
|
||||||
@region = region
|
@region = region
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def process_webhook(webhook_body)
|
||||||
|
parsed = JSON.parse(webhook_body)
|
||||||
|
|
||||||
|
type = parsed["webhook_type"]
|
||||||
|
code = parsed["webhook_code"]
|
||||||
|
|
||||||
|
item = PlaidItem.find_by(plaid_id: parsed["item_id"])
|
||||||
|
|
||||||
|
case [ type, code ]
|
||||||
|
when [ "TRANSACTIONS", "SYNC_UPDATES_AVAILABLE" ]
|
||||||
|
item.sync_later
|
||||||
|
when [ "INVESTMENTS_TRANSACTIONS", "DEFAULT_UPDATE" ]
|
||||||
|
item.sync_later
|
||||||
|
when [ "HOLDINGS", "DEFAULT_UPDATE" ]
|
||||||
|
item.sync_later
|
||||||
|
else
|
||||||
|
Rails.logger.warn("Unhandled Plaid webhook type: #{type}:#{code}")
|
||||||
|
end
|
||||||
|
rescue => error
|
||||||
|
# Processing errors shouldn't return a 400 to Plaid since they are internal, so capture silently
|
||||||
|
Sentry.capture_exception(error)
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_webhook!(verification_header, raw_body)
|
||||||
|
jwks_loader = ->(options) do
|
||||||
|
key_id = options[:kid]
|
||||||
|
|
||||||
|
jwk_response = client.webhook_verification_key_get(
|
||||||
|
Plaid::WebhookVerificationKeyGetRequest.new(key_id: key_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
jwks = JWT::JWK::Set.new([ jwk_response.key.to_hash ])
|
||||||
|
|
||||||
|
jwks.filter! { |key| key[:use] == "sig" }
|
||||||
|
jwks
|
||||||
|
end
|
||||||
|
|
||||||
|
payload, _header = JWT.decode(
|
||||||
|
verification_header, nil, true,
|
||||||
|
{
|
||||||
|
algorithms: [ "ES256" ],
|
||||||
|
jwks: jwks_loader,
|
||||||
|
verify_expiration: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
issued_at = Time.at(payload["iat"])
|
||||||
|
raise JWT::VerificationError, "Webhook is too old" if Time.now - issued_at > 5.minutes
|
||||||
|
|
||||||
|
expected_hash = payload["request_body_sha256"]
|
||||||
|
actual_hash = Digest::SHA256.hexdigest(raw_body)
|
||||||
|
raise JWT::VerificationError, "Invalid webhook body hash" unless ActiveSupport::SecurityUtils.secure_compare(expected_hash, actual_hash)
|
||||||
|
end
|
||||||
|
|
||||||
def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil)
|
def get_link_token(user_id:, webhooks_url:, redirect_url:, accountable_type: nil)
|
||||||
request = Plaid::LinkTokenCreateRequest.new({
|
request = Plaid::LinkTokenCreateRequest.new({
|
||||||
user: { client_user_id: user_id },
|
user: { client_user_id: user_id },
|
||||||
|
|
|
@ -182,6 +182,7 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
namespace :webhooks do
|
namespace :webhooks do
|
||||||
post "plaid"
|
post "plaid"
|
||||||
|
post "plaid_eu"
|
||||||
post "stripe"
|
post "stripe"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue