diff --git a/crates/api_models/src/webhooks.rs b/crates/api_models/src/webhooks.rs index 39d9d4b16c..b884508211 100644 --- a/crates/api_models/src/webhooks.rs +++ b/crates/api_models/src/webhooks.rs @@ -98,9 +98,3 @@ pub enum OutgoingWebhookContent { RefundDetails(refunds::RefundResponse), DisputeDetails(Box), } - -pub trait OutgoingWebhookType: - Serialize + From + Sync + Send + std::fmt::Debug -{ -} -impl OutgoingWebhookType for OutgoingWebhook {} diff --git a/crates/router/src/compatibility/stripe/webhooks.rs b/crates/router/src/compatibility/stripe/webhooks.rs index 3e4ebf5b08..7fc008a04b 100644 --- a/crates/router/src/compatibility/stripe/webhooks.rs +++ b/crates/router/src/compatibility/stripe/webhooks.rs @@ -2,21 +2,70 @@ use api_models::{ enums::DisputeStatus, webhooks::{self as api}, }; +use common_utils::{crypto::SignMessage, date_time, ext_traits}; +use error_stack::{IntoReport, ResultExt}; +use router_env::logger; use serde::Serialize; use super::{ payment_intents::types::StripePaymentIntentResponse, refunds::types::StripeRefundResponse, }; +use crate::{ + core::{errors, webhooks::types::OutgoingWebhookType}, + headers, + services::request::Maskable, +}; #[derive(Serialize, Debug)] pub struct StripeOutgoingWebhook { - id: Option, + id: String, #[serde(rename = "type")] stype: &'static str, + object: &'static str, data: StripeWebhookObject, + created: u64, + // api_version: "2019-11-05", // not used } -impl api::OutgoingWebhookType for StripeOutgoingWebhook {} +impl OutgoingWebhookType for StripeOutgoingWebhook { + fn get_outgoing_webhooks_signature( + &self, + payment_response_hash_key: Option, + ) -> errors::CustomResult, errors::WebhooksFlowError> { + let timestamp = self.created; + + let payment_response_hash_key = payment_response_hash_key + .ok_or(errors::WebhooksFlowError::MerchantConfigNotFound) + .into_report() + .attach_printable("For stripe compatibility payment_response_hash_key is mandatory")?; + + let webhook_signature_payload = + ext_traits::Encode::::encode_to_string_of_json(self) + .change_context(errors::WebhooksFlowError::OutgoingWebhookEncodingFailed) + .attach_printable("failed encoding outgoing webhook payload")?; + + let new_signature_payload = format!("{timestamp}.{webhook_signature_payload}"); + let v1 = hex::encode( + common_utils::crypto::HmacSha256::sign_message( + &common_utils::crypto::HmacSha256, + payment_response_hash_key.as_bytes(), + new_signature_payload.as_bytes(), + ) + .change_context(errors::WebhooksFlowError::OutgoingWebhookSigningFailed) + .attach_printable("Failed to sign the message")?, + ); + + let t = timestamp; + Ok(Some(format!("t={t},v1={v1}"))) + } + + fn add_webhook_header(header: &mut Vec<(String, Maskable)>, signature: String) { + header.push(( + headers::STRIPE_COMPATIBLE_WEBHOOK_SIGNATURE.to_string(), + signature.into(), + )) + } +} #[derive(Serialize, Debug)] #[serde(tag = "type", content = "object", rename_all = "snake_case")] @@ -76,13 +125,46 @@ impl From for StripeDisputeStatus { } } +fn get_stripe_event_type(event_type: api_models::enums::EventType) -> &'static str { + match event_type { + api_models::enums::EventType::PaymentSucceeded => "payment_intent.succeeded", + api_models::enums::EventType::PaymentFailed => "payment_intent.payment_failed", + api_models::enums::EventType::PaymentProcessing => "payment_intent.processing", + + // the below are not really stripe compatible because stripe doesn't provide this + api_models::enums::EventType::ActionRequired => "action.required", + api_models::enums::EventType::RefundSucceeded => "refund.succeeded", + api_models::enums::EventType::RefundFailed => "refund.failed", + api_models::enums::EventType::DisputeOpened => "dispute.failed", + api_models::enums::EventType::DisputeExpired => "dispute.expired", + api_models::enums::EventType::DisputeAccepted => "dispute.accepted", + api_models::enums::EventType::DisputeCancelled => "dispute.cancelled", + api_models::enums::EventType::DisputeChallenged => "dispute.challenged", + api_models::enums::EventType::DisputeWon => "dispute.won", + api_models::enums::EventType::DisputeLost => "dispute.lost", + } +} + impl From for StripeOutgoingWebhook { fn from(value: api::OutgoingWebhook) -> Self { - let data: StripeWebhookObject = value.content.into(); Self { - id: data.get_id(), - stype: "webhook_endpoint", - data, + id: value.event_id, + stype: get_stripe_event_type(value.event_type), + data: StripeWebhookObject::from(value.content), + object: "event", + // put this conversion it into a function + created: u64::try_from(value.timestamp.assume_utc().unix_timestamp()).unwrap_or_else( + |error| { + logger::error!( + %error, + "incorrect value for `webhook.timestamp` provided {}", value.timestamp + ); + // Current timestamp converted to Unix timestamp should have a positive value + // for many years to come + u64::try_from(date_time::now().assume_utc().unix_timestamp()) + .unwrap_or_default() + }, + ), } } } @@ -100,13 +182,3 @@ impl From for StripeWebhookObject { } } } - -impl StripeWebhookObject { - fn get_id(&self) -> Option { - match self { - Self::PaymentIntent(p) => p.id.to_owned(), - Self::Refund(r) => Some(r.id.to_owned()), - Self::Dispute(d) => Some(d.id.to_owned()), - } - } -} diff --git a/crates/router/src/core/webhooks.rs b/crates/router/src/core/webhooks.rs index 35dbeb0672..7cf8dc9c3f 100644 --- a/crates/router/src/core/webhooks.rs +++ b/crates/router/src/core/webhooks.rs @@ -1,7 +1,6 @@ -pub mod transformers; +pub mod types; pub mod utils; -use common_utils::{crypto::SignMessage, ext_traits}; use error_stack::{report, IntoReport, ResultExt}; use masking::ExposeInterface; use router_env::{instrument, tracing}; @@ -14,11 +13,11 @@ use crate::{ errors::{self, CustomResult, RouterResponse}, payments, refunds, }, - headers, logger, + logger, routes::AppState, services, types::{ - self, api, domain, + self as router_types, api, domain, storage::{self, enums}, transformers::{ForeignInto, ForeignTryInto}, }, @@ -29,7 +28,7 @@ const OUTGOING_WEBHOOK_TIMEOUT_SECS: u64 = 5; const MERCHANT_ID: &str = "merchant_id"; #[instrument(skip_all)] -pub async fn payments_incoming_webhook_flow( +pub async fn payments_incoming_webhook_flow( state: AppState, merchant_account: domain::MerchantAccount, key_store: domain::MerchantKeyStore, @@ -108,7 +107,7 @@ pub async fn payments_incoming_webhook_flow( } #[instrument(skip_all)] -pub async fn refunds_incoming_webhook_flow( +pub async fn refunds_incoming_webhook_flow( state: AppState, merchant_account: domain::MerchantAccount, key_store: domain::MerchantKeyStore, @@ -325,7 +324,7 @@ pub async fn get_or_update_dispute_object( } #[instrument(skip_all)] -pub async fn disputes_incoming_webhook_flow( +pub async fn disputes_incoming_webhook_flow( state: AppState, merchant_account: domain::MerchantAccount, webhook_details: api::IncomingWebhookDetails, @@ -388,7 +387,7 @@ pub async fn disputes_incoming_webhook_flow( } } -async fn bank_transfer_webhook_flow( +async fn bank_transfer_webhook_flow( state: AppState, merchant_account: domain::MerchantAccount, key_store: domain::MerchantKeyStore, @@ -465,7 +464,7 @@ async fn bank_transfer_webhook_flow( #[allow(clippy::too_many_arguments)] #[instrument(skip_all)] -pub async fn create_event_and_trigger_outgoing_webhook( +pub async fn create_event_and_trigger_outgoing_webhook( state: AppState, merchant_account: domain::MerchantAccount, event_type: enums::EventType, @@ -506,34 +505,9 @@ pub async fn create_event_and_trigger_outgoing_webhook::encode_to_string_of_json(&outgoing_webhook) - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("failed encoding outgoing webhook payload")?; - - let outgoing_webhooks_signature = merchant_account - .payment_response_hash_key - .clone() - .map(|key| { - common_utils::crypto::HmacSha512::sign_message( - &common_utils::crypto::HmacSha512, - key.as_bytes(), - webhook_signature_payload.as_bytes(), - ) - }) - .transpose() - .change_context(errors::ApiErrorResponse::WebhookProcessingFailure) - .attach_printable("Failed to sign the message")? - .map(hex::encode); - arbiter.spawn(async move { - let result = trigger_webhook_to_merchant::( - merchant_account, - outgoing_webhook, - outgoing_webhooks_signature, - &state, - ) - .await; + let result = + trigger_webhook_to_merchant::(merchant_account, outgoing_webhook, &state).await; if let Err(e) = result { logger::error!(?e); @@ -544,10 +518,9 @@ pub async fn create_event_and_trigger_outgoing_webhook( +pub async fn trigger_webhook_to_merchant( merchant_account: domain::MerchantAccount, webhook: api::OutgoingWebhook, - outgoing_webhooks_signature: Option, state: &AppState, ) -> CustomResult<(), errors::WebhooksFlowError> { let webhook_details_json = merchant_account @@ -570,7 +543,10 @@ pub async fn trigger_webhook_to_merchant( let transformed_outgoing_webhook = W::from(webhook); - let transformed_outgoing_webhook_string = types::RequestBody::log_and_get_request_body( + let outgoing_webhooks_signature = transformed_outgoing_webhook + .get_outgoing_webhooks_signature(merchant_account.payment_response_hash_key.clone())?; + + let transformed_outgoing_webhook_string = router_types::RequestBody::log_and_get_request_body( &transformed_outgoing_webhook, Encode::::encode_to_string_of_json, ) @@ -583,7 +559,7 @@ pub async fn trigger_webhook_to_merchant( )]; if let Some(signature) = outgoing_webhooks_signature { - header.push((headers::X_WEBHOOK_SIGNATURE.to_string(), signature.into())) + W::add_webhook_header(&mut header, signature) } let request = services::RequestBuilder::new() @@ -649,7 +625,7 @@ pub async fn trigger_webhook_to_merchant( } #[instrument(skip_all)] -pub async fn webhooks_core( +pub async fn webhooks_core( state: &AppState, req: &actix_web::HttpRequest, merchant_account: domain::MerchantAccount, diff --git a/crates/router/src/core/webhooks/transformers.rs b/crates/router/src/core/webhooks/transformers.rs deleted file mode 100644 index 8b13789179..0000000000 --- a/crates/router/src/core/webhooks/transformers.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crates/router/src/core/webhooks/types.rs b/crates/router/src/core/webhooks/types.rs new file mode 100644 index 0000000000..8feb54e156 --- /dev/null +++ b/crates/router/src/core/webhooks/types.rs @@ -0,0 +1,45 @@ +use api_models::webhooks; +use common_utils::{crypto::SignMessage, ext_traits}; +use error_stack::ResultExt; +use serde::Serialize; + +use crate::{core::errors, headers, services::request::Maskable}; + +pub trait OutgoingWebhookType: + Serialize + From + Sync + Send + std::fmt::Debug +{ + fn get_outgoing_webhooks_signature( + &self, + payment_response_hash_key: Option, + ) -> errors::CustomResult, errors::WebhooksFlowError>; + + fn add_webhook_header(header: &mut Vec<(String, Maskable)>, signature: String); +} + +impl OutgoingWebhookType for webhooks::OutgoingWebhook { + fn get_outgoing_webhooks_signature( + &self, + payment_response_hash_key: Option, + ) -> errors::CustomResult, errors::WebhooksFlowError> { + let webhook_signature_payload = + ext_traits::Encode::::encode_to_string_of_json(self) + .change_context(errors::WebhooksFlowError::OutgoingWebhookEncodingFailed) + .attach_printable("failed encoding outgoing webhook payload")?; + + Ok(payment_response_hash_key + .map(|key| { + common_utils::crypto::HmacSha512::sign_message( + &common_utils::crypto::HmacSha512, + key.as_bytes(), + webhook_signature_payload.as_bytes(), + ) + }) + .transpose() + .change_context(errors::WebhooksFlowError::OutgoingWebhookSigningFailed) + .attach_printable("Failed to sign the message")? + .map(hex::encode)) + } + fn add_webhook_header(header: &mut Vec<(String, Maskable)>, signature: String) { + header.push((headers::X_WEBHOOK_SIGNATURE.to_string(), signature.into())) + } +} diff --git a/crates/router/src/lib.rs b/crates/router/src/lib.rs index c41898b5ca..ad3ee4c42d 100644 --- a/crates/router/src/lib.rs +++ b/crates/router/src/lib.rs @@ -65,6 +65,8 @@ pub mod headers { pub const X_ACCEPT_VERSION: &str = "X-Accept-Version"; pub const X_DATE: &str = "X-Date"; pub const X_WEBHOOK_SIGNATURE: &str = "X-Webhook-Signature-512"; + + pub const STRIPE_COMPATIBLE_WEBHOOK_SIGNATURE: &str = "Stripe-Signature"; } pub mod pii { diff --git a/crates/router/src/routes/webhooks.rs b/crates/router/src/routes/webhooks.rs index 9566038127..188fd0883b 100644 --- a/crates/router/src/routes/webhooks.rs +++ b/crates/router/src/routes/webhooks.rs @@ -3,13 +3,12 @@ use router_env::{instrument, tracing, Flow}; use super::app::AppState; use crate::{ - core::webhooks, + core::webhooks::{self, types}, services::{api, authentication as auth}, - types::api as api_types, }; #[instrument(skip_all, fields(flow = ?Flow::IncomingWebhookReceive))] -pub async fn receive_incoming_webhook( +pub async fn receive_incoming_webhook( state: web::Data, req: HttpRequest, body: web::Bytes, diff --git a/crates/router/src/types/api/webhooks.rs b/crates/router/src/types/api/webhooks.rs index fd0adf1566..14bd5695bb 100644 --- a/crates/router/src/types/api/webhooks.rs +++ b/crates/router/src/types/api/webhooks.rs @@ -1,7 +1,7 @@ use api_models::admin::MerchantConnectorWebhookDetails; pub use api_models::webhooks::{ IncomingWebhookDetails, IncomingWebhookEvent, MerchantWebhookConfig, ObjectReferenceId, - OutgoingWebhook, OutgoingWebhookContent, OutgoingWebhookType, WebhookFlow, + OutgoingWebhook, OutgoingWebhookContent, WebhookFlow, }; use common_utils::ext_traits::ValueExt; use error_stack::{IntoReport, ResultExt};