1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-24 23:59:40 +02:00

fix: Plaid webhook verification (#1824)

* Fix Plaid webhook verification

* Fix client creation in webhook controller
This commit is contained in:
Zach Gollwitzer 2025-02-07 10:35:42 -05:00 committed by GitHub
parent 331de2f997
commit 5eb5ec7aef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 87 additions and 62 deletions

View file

@ -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 },