diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 675c3d93..2a02edb4 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -53,7 +53,7 @@ module AccountableResource private def set_link_token @us_link_token = Current.family.get_link_token( - webhooks_url: webhooks_url, + webhooks_url: plaid_us_webhooks_url, redirect_url: accounts_url, accountable_type: accountable_type.name, region: :us @@ -61,7 +61,7 @@ module AccountableResource if Current.family.eu? @eu_link_token = Current.family.get_link_token( - webhooks_url: webhooks_url, + webhooks_url: plaid_eu_webhooks_url, redirect_url: accounts_url, accountable_type: accountable_type.name, region: :eu @@ -69,11 +69,16 @@ module AccountableResource end end - def webhooks_url + def plaid_us_webhooks_url return webhooks_plaid_url if Rails.env.production? - base_url = ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) - base_url + "/webhooks/plaid" + ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/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 def accountable_type diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 3f6af0e6..f235ec07 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -6,8 +6,25 @@ class WebhooksController < ApplicationController webhook_body = request.body.read plaid_verification_header = request.headers["Plaid-Verification"] - Provider::Plaid.validate_webhook!(plaid_verification_header, webhook_body) - Provider::Plaid.process_webhook(webhook_body) + client = Provider::Plaid.new(Rails.application.config.plaid, region: :us) + + 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 rescue => error diff --git a/app/models/concerns/plaidable.rb b/app/models/concerns/plaidable.rb index 062eab8e..8765559d 100644 --- a/app/models/concerns/plaidable.rb +++ b/app/models/concerns/plaidable.rb @@ -3,11 +3,11 @@ module Plaidable class_methods do 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 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 def plaid_provider_for_region(region) diff --git a/app/models/provider/plaid.rb b/app/models/provider/plaid.rb index 33f7af2f..5c21fb6a 100644 --- a/app/models/provider/plaid.rb +++ b/app/models/provider/plaid.rb @@ -4,65 +4,67 @@ class Provider::Plaid MAYBE_SUPPORTED_PLAID_PRODUCTS = %w[transactions investments liabilities].freeze MAX_HISTORY_DAYS = Rails.env.development? ? 90 : 730 - class << self - 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) + def initialize(config, region: :us) @client = Plaid::PlaidApi.new( Plaid::ApiClient.new(config) ) @region = region 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) request = Plaid::LinkTokenCreateRequest.new({ user: { client_user_id: user_id }, diff --git a/config/routes.rb b/config/routes.rb index bf6eb972..26fcc22a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -182,6 +182,7 @@ Rails.application.routes.draw do namespace :webhooks do post "plaid" + post "plaid_eu" post "stripe" end