diff --git a/crates/api_models/src/admin.rs b/crates/api_models/src/admin.rs index efde4a048323..6bb4fd4afa0f 100644 --- a/crates/api_models/src/admin.rs +++ b/crates/api_models/src/admin.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use common_utils::{ crypto::{Encryptable, OptionalEncryptableName}, pii, @@ -614,6 +616,36 @@ pub struct MerchantConnectorCreate { pub status: Option, } +// Different patterns of authentication. +#[derive(Default, Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(tag = "auth_type")] +pub enum ConnectorAuthType { + TemporaryAuth, + HeaderKey { + api_key: Secret, + }, + BodyKey { + api_key: Secret, + key1: Secret, + }, + SignatureKey { + api_key: Secret, + key1: Secret, + api_secret: Secret, + }, + MultiAuthKey { + api_key: Secret, + key1: Secret, + api_secret: Secret, + key2: Secret, + }, + CurrencyAuthKey { + auth_key_map: HashMap, + }, + #[default] + NoKey, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(deny_unknown_fields)] pub struct MerchantConnectorWebhookDetails { diff --git a/crates/api_models/src/lib.rs b/crates/api_models/src/lib.rs index ab40a96582bb..8ef40d319140 100644 --- a/crates/api_models/src/lib.rs +++ b/crates/api_models/src/lib.rs @@ -27,4 +27,5 @@ pub mod routing; pub mod surcharge_decision_configs; pub mod user; pub mod verifications; +pub mod verify_connector; pub mod webhooks; diff --git a/crates/api_models/src/verify_connector.rs b/crates/api_models/src/verify_connector.rs new file mode 100644 index 000000000000..1db5a19a030a --- /dev/null +++ b/crates/api_models/src/verify_connector.rs @@ -0,0 +1,11 @@ +use common_utils::events::{ApiEventMetric, ApiEventsType}; + +use crate::{admin, enums}; + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +pub struct VerifyConnectorRequest { + pub connector_name: enums::Connector, + pub connector_account_details: admin::ConnectorAuthType, +} + +common_utils::impl_misc_api_event_type!(VerifyConnectorRequest); diff --git a/crates/router/src/consts.rs b/crates/router/src/consts.rs index 61072d06221b..49c5cfacad1f 100644 --- a/crates/router/src/consts.rs +++ b/crates/router/src/consts.rs @@ -65,3 +65,8 @@ pub const JWT_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24 * 2; // 2 days #[cfg(feature = "email")] pub const EMAIL_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24; // 1 day pub const ROLE_ID_ORGANIZATION_ADMIN: &str = "org_admin"; + +#[cfg(feature = "olap")] +pub const VERIFY_CONNECTOR_ID_PREFIX: &str = "conn_verify"; +#[cfg(feature = "olap")] +pub const VERIFY_CONNECTOR_MERCHANT_ID: &str = "test_merchant"; diff --git a/crates/router/src/core.rs b/crates/router/src/core.rs index cff2dc8e58f1..30fe1a1ce8cb 100644 --- a/crates/router/src/core.rs +++ b/crates/router/src/core.rs @@ -28,4 +28,6 @@ pub mod user; pub mod utils; #[cfg(all(feature = "olap", feature = "kms"))] pub mod verification; +#[cfg(feature = "olap")] +pub mod verify_connector; pub mod webhooks; diff --git a/crates/router/src/core/verify_connector.rs b/crates/router/src/core/verify_connector.rs new file mode 100644 index 000000000000..e837e8b8b259 --- /dev/null +++ b/crates/router/src/core/verify_connector.rs @@ -0,0 +1,63 @@ +use api_models::{enums::Connector, verify_connector::VerifyConnectorRequest}; +use error_stack::{IntoReport, ResultExt}; + +use crate::{ + connector, + core::errors, + services, + types::{ + api, + api::verify_connector::{self as types, VerifyConnector}, + }, + utils::verify_connector as utils, + AppState, +}; + +pub async fn verify_connector_credentials( + state: AppState, + req: VerifyConnectorRequest, +) -> errors::RouterResponse<()> { + let boxed_connector = api::ConnectorData::get_connector_by_name( + &state.conf.connectors, + &req.connector_name.to_string(), + api::GetToken::Connector, + None, + ) + .change_context(errors::ApiErrorResponse::IncorrectConnectorNameGiven)?; + + let card_details = utils::get_test_card_details(req.connector_name)? + .ok_or(errors::ApiErrorResponse::FlowNotSupported { + flow: "Verify credentials".to_string(), + connector: req.connector_name.to_string(), + }) + .into_report()?; + + match req.connector_name { + Connector::Stripe => { + connector::Stripe::verify( + &state, + types::VerifyConnectorData { + connector: *boxed_connector.connector, + connector_auth: req.connector_account_details.into(), + card_details, + }, + ) + .await + } + Connector::Paypal => connector::Paypal::get_access_token( + &state, + types::VerifyConnectorData { + connector: *boxed_connector.connector, + connector_auth: req.connector_account_details.into(), + card_details, + }, + ) + .await + .map(|_| services::ApplicationResponse::StatusOk), + _ => Err(errors::ApiErrorResponse::FlowNotSupported { + flow: "Verify credentials".to_string(), + connector: req.connector_name.to_string(), + }) + .into_report(), + } +} diff --git a/crates/router/src/routes.rs b/crates/router/src/routes.rs index 37cc1339e1a1..22c2610d3255 100644 --- a/crates/router/src/routes.rs +++ b/crates/router/src/routes.rs @@ -29,6 +29,8 @@ pub mod routing; pub mod user; #[cfg(all(feature = "olap", feature = "kms"))] pub mod verification; +#[cfg(feature = "olap")] +pub mod verify_connector; pub mod webhooks; pub mod locker_migration; diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 80993429c4e2..2a7e1ab61905 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -30,6 +30,8 @@ use super::{cache::*, health::*}; use super::{configs::*, customers::*, mandates::*, payments::*, refunds::*}; #[cfg(feature = "oltp")] use super::{ephemeral_key::*, payment_methods::*, webhooks::*}; +#[cfg(feature = "olap")] +use crate::routes::verify_connector::payment_connector_verify; pub use crate::{ configs::settings, db::{StorageImpl, StorageInterface}, @@ -548,6 +550,10 @@ impl MerchantConnectorAccount { use super::admin::*; route = route + .service( + web::resource("/connectors/verify") + .route(web::post().to(payment_connector_verify)), + ) .service( web::resource("/{merchant_id}/connectors") .route(web::post().to(payment_connector_create)) diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 5c2ad123749c..c7369b9e4d52 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -147,7 +147,9 @@ impl From for ApiIdentifier { | Flow::GsmRuleUpdate | Flow::GsmRuleDelete => Self::Gsm, - Flow::UserConnectAccount | Flow::ChangePassword => Self::User, + Flow::UserConnectAccount | Flow::ChangePassword | Flow::VerifyPaymentConnector => { + Self::User + } } } } diff --git a/crates/router/src/routes/verify_connector.rs b/crates/router/src/routes/verify_connector.rs new file mode 100644 index 000000000000..bfb1b781ada4 --- /dev/null +++ b/crates/router/src/routes/verify_connector.rs @@ -0,0 +1,28 @@ +use actix_web::{web, HttpRequest, HttpResponse}; +use api_models::verify_connector::VerifyConnectorRequest; +use router_env::{instrument, tracing, Flow}; + +use super::AppState; +use crate::{ + core::{api_locking, verify_connector}, + services::{self, authentication as auth, authorization::permissions::Permission}, +}; + +#[instrument(skip_all, fields(flow = ?Flow::VerifyPaymentConnector))] +pub async fn payment_connector_verify( + state: web::Data, + req: HttpRequest, + json_payload: web::Json, +) -> HttpResponse { + let flow = Flow::VerifyPaymentConnector; + Box::pin(services::server_wrap( + flow, + state, + &req, + json_payload.into_inner(), + |state, _: (), req| verify_connector::verify_connector_credentials(state, req), + &auth::JWTAuth(Permission::MerchantConnectorAccountWrite), + api_locking::LockAction::NotApplicable, + )) + .await +} diff --git a/crates/router/src/types.rs b/crates/router/src/types.rs index cd37fbb549d9..c3118f0c05be 100644 --- a/crates/router/src/types.rs +++ b/crates/router/src/types.rs @@ -33,7 +33,7 @@ use crate::{ payments::{PaymentData, RecurringMandatePaymentData}, }, services, - types::storage::payment_attempt::PaymentAttemptExt, + types::{storage::payment_attempt::PaymentAttemptExt, transformers::ForeignFrom}, utils::OptionExt, }; @@ -942,6 +942,78 @@ pub enum ConnectorAuthType { NoKey, } +impl From for ConnectorAuthType { + fn from(value: api_models::admin::ConnectorAuthType) -> Self { + match value { + api_models::admin::ConnectorAuthType::TemporaryAuth => Self::TemporaryAuth, + api_models::admin::ConnectorAuthType::HeaderKey { api_key } => { + Self::HeaderKey { api_key } + } + api_models::admin::ConnectorAuthType::BodyKey { api_key, key1 } => { + Self::BodyKey { api_key, key1 } + } + api_models::admin::ConnectorAuthType::SignatureKey { + api_key, + key1, + api_secret, + } => Self::SignatureKey { + api_key, + key1, + api_secret, + }, + api_models::admin::ConnectorAuthType::MultiAuthKey { + api_key, + key1, + api_secret, + key2, + } => Self::MultiAuthKey { + api_key, + key1, + api_secret, + key2, + }, + api_models::admin::ConnectorAuthType::CurrencyAuthKey { auth_key_map } => { + Self::CurrencyAuthKey { auth_key_map } + } + api_models::admin::ConnectorAuthType::NoKey => Self::NoKey, + } + } +} + +impl ForeignFrom for api_models::admin::ConnectorAuthType { + fn foreign_from(from: ConnectorAuthType) -> Self { + match from { + ConnectorAuthType::TemporaryAuth => Self::TemporaryAuth, + ConnectorAuthType::HeaderKey { api_key } => Self::HeaderKey { api_key }, + ConnectorAuthType::BodyKey { api_key, key1 } => Self::BodyKey { api_key, key1 }, + ConnectorAuthType::SignatureKey { + api_key, + key1, + api_secret, + } => Self::SignatureKey { + api_key, + key1, + api_secret, + }, + ConnectorAuthType::MultiAuthKey { + api_key, + key1, + api_secret, + key2, + } => Self::MultiAuthKey { + api_key, + key1, + api_secret, + key2, + }, + ConnectorAuthType::CurrencyAuthKey { auth_key_map } => { + Self::CurrencyAuthKey { auth_key_map } + } + ConnectorAuthType::NoKey => Self::NoKey, + } + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct ConnectorsList { pub connectors: Vec, diff --git a/crates/router/src/types/api.rs b/crates/router/src/types/api.rs index bcb3a9add553..96bcaca3ed5d 100644 --- a/crates/router/src/types/api.rs +++ b/crates/router/src/types/api.rs @@ -13,6 +13,8 @@ pub mod payments; pub mod payouts; pub mod refunds; pub mod routing; +#[cfg(feature = "olap")] +pub mod verify_connector; pub mod webhooks; use std::{fmt::Debug, str::FromStr}; diff --git a/crates/router/src/types/api/verify_connector.rs b/crates/router/src/types/api/verify_connector.rs new file mode 100644 index 000000000000..3e3511ccb98f --- /dev/null +++ b/crates/router/src/types/api/verify_connector.rs @@ -0,0 +1,181 @@ +pub mod paypal; +pub mod stripe; + +use error_stack::{IntoReport, ResultExt}; + +use crate::{ + consts, + core::errors, + services, + services::ConnectorIntegration, + types::{self, api, storage::enums as storage_enums}, + AppState, +}; + +#[derive(Clone, Debug)] +pub struct VerifyConnectorData { + pub connector: &'static (dyn types::api::Connector + Sync), + pub connector_auth: types::ConnectorAuthType, + pub card_details: api::Card, +} + +impl VerifyConnectorData { + fn get_payment_authorize_data(&self) -> types::PaymentsAuthorizeData { + types::PaymentsAuthorizeData { + payment_method_data: api::PaymentMethodData::Card(self.card_details.clone()), + email: None, + amount: 1000, + confirm: true, + currency: storage_enums::Currency::USD, + mandate_id: None, + webhook_url: None, + customer_id: None, + off_session: None, + browser_info: None, + session_token: None, + order_details: None, + order_category: None, + capture_method: None, + enrolled_for_3ds: false, + router_return_url: None, + surcharge_details: None, + setup_future_usage: None, + payment_experience: None, + payment_method_type: None, + statement_descriptor: None, + setup_mandate_details: None, + complete_authorize_url: None, + related_transaction_id: None, + statement_descriptor_suffix: None, + } + } + + fn get_router_data( + &self, + request_data: R1, + access_token: Option, + ) -> types::RouterData { + let attempt_id = + common_utils::generate_id_with_default_len(consts::VERIFY_CONNECTOR_ID_PREFIX); + types::RouterData { + flow: std::marker::PhantomData, + status: storage_enums::AttemptStatus::Started, + request: request_data, + response: Err(errors::ApiErrorResponse::InternalServerError.into()), + connector: self.connector.id().to_string(), + auth_type: storage_enums::AuthenticationType::NoThreeDs, + test_mode: None, + return_url: None, + attempt_id: attempt_id.clone(), + description: None, + customer_id: None, + merchant_id: consts::VERIFY_CONNECTOR_MERCHANT_ID.to_string(), + reference_id: None, + access_token, + session_token: None, + payment_method: storage_enums::PaymentMethod::Card, + amount_captured: None, + preprocessing_id: None, + payment_method_id: None, + connector_customer: None, + connector_auth_type: self.connector_auth.clone(), + connector_meta_data: None, + payment_method_token: None, + connector_api_version: None, + recurring_mandate_payment_data: None, + connector_request_reference_id: attempt_id, + address: types::PaymentAddress { + shipping: None, + billing: None, + }, + payment_id: common_utils::generate_id_with_default_len( + consts::VERIFY_CONNECTOR_ID_PREFIX, + ), + #[cfg(feature = "payouts")] + payout_method_data: None, + #[cfg(feature = "payouts")] + quote_id: None, + payment_method_balance: None, + connector_http_status_code: None, + external_latency: None, + apple_pay_flow: None, + } + } +} + +#[async_trait::async_trait] +pub trait VerifyConnector { + async fn verify( + state: &AppState, + connector_data: VerifyConnectorData, + ) -> errors::RouterResponse<()> { + let authorize_data = connector_data.get_payment_authorize_data(); + let access_token = Self::get_access_token(state, connector_data.clone()).await?; + let router_data = connector_data.get_router_data(authorize_data, access_token); + + let request = connector_data + .connector + .build_request(&router_data, &state.conf.connectors) + .change_context(errors::ApiErrorResponse::InvalidRequestData { + message: "Payment request cannot be built".to_string(), + })? + .ok_or(errors::ApiErrorResponse::InternalServerError)?; + + let response = services::call_connector_api(&state.to_owned(), request) + .await + .change_context(errors::ApiErrorResponse::InternalServerError)?; + + match response { + Ok(_) => Ok(services::ApplicationResponse::StatusOk), + Err(error_response) => { + Self::handle_payment_error_response::< + api::Authorize, + types::PaymentsAuthorizeData, + types::PaymentsResponseData, + >(connector_data.connector, error_response) + .await + } + } + } + + async fn get_access_token( + _state: &AppState, + _connector_data: VerifyConnectorData, + ) -> errors::CustomResult, errors::ApiErrorResponse> { + // AccessToken is None for the connectors without the AccessToken Flow. + // If a connector has that, then it should override this implementation. + Ok(None) + } + + async fn handle_payment_error_response( + connector: &(dyn types::api::Connector + Sync), + error_response: types::Response, + ) -> errors::RouterResponse<()> + where + dyn types::api::Connector + Sync: ConnectorIntegration, + { + let error = connector + .get_error_response(error_response) + .change_context(errors::ApiErrorResponse::InternalServerError)?; + Err(errors::ApiErrorResponse::InvalidRequestData { + message: error.reason.unwrap_or(error.message), + }) + .into_report() + } + + async fn handle_access_token_error_response( + connector: &(dyn types::api::Connector + Sync), + error_response: types::Response, + ) -> errors::RouterResult> + where + dyn types::api::Connector + Sync: ConnectorIntegration, + { + let error = connector + .get_error_response(error_response) + .change_context(errors::ApiErrorResponse::InternalServerError)?; + Err(errors::ApiErrorResponse::InvalidRequestData { + message: error.reason.unwrap_or(error.message), + }) + .into_report() + } +} diff --git a/crates/router/src/types/api/verify_connector/paypal.rs b/crates/router/src/types/api/verify_connector/paypal.rs new file mode 100644 index 000000000000..33e848f909df --- /dev/null +++ b/crates/router/src/types/api/verify_connector/paypal.rs @@ -0,0 +1,54 @@ +use error_stack::ResultExt; + +use super::{VerifyConnector, VerifyConnectorData}; +use crate::{ + connector, + core::errors, + routes::AppState, + services, + types::{self, api}, +}; + +#[async_trait::async_trait] +impl VerifyConnector for connector::Paypal { + async fn get_access_token( + state: &AppState, + connector_data: VerifyConnectorData, + ) -> errors::CustomResult, errors::ApiErrorResponse> { + let token_data: types::AccessTokenRequestData = + connector_data.connector_auth.clone().try_into()?; + let router_data = connector_data.get_router_data(token_data, None); + + let request = connector_data + .connector + .build_request(&router_data, &state.conf.connectors) + .change_context(errors::ApiErrorResponse::InvalidRequestData { + message: "Payment request cannot be built".to_string(), + })? + .ok_or(errors::ApiErrorResponse::InternalServerError)?; + + let response = services::call_connector_api(&state.to_owned(), request) + .await + .change_context(errors::ApiErrorResponse::InternalServerError)?; + + match response { + Ok(res) => Some( + connector_data + .connector + .handle_response(&router_data, res) + .change_context(errors::ApiErrorResponse::InternalServerError)? + .response + .map_err(|_| errors::ApiErrorResponse::InternalServerError.into()), + ) + .transpose(), + Err(response_data) => { + Self::handle_access_token_error_response::< + api::AccessTokenAuth, + types::AccessTokenRequestData, + types::AccessToken, + >(connector_data.connector, response_data) + .await + } + } + } +} diff --git a/crates/router/src/types/api/verify_connector/stripe.rs b/crates/router/src/types/api/verify_connector/stripe.rs new file mode 100644 index 000000000000..ece9fa15a1d9 --- /dev/null +++ b/crates/router/src/types/api/verify_connector/stripe.rs @@ -0,0 +1,36 @@ +use error_stack::{IntoReport, ResultExt}; +use router_env::env; + +use super::VerifyConnector; +use crate::{ + connector, + core::errors, + services::{self, ConnectorIntegration}, + types, +}; + +#[async_trait::async_trait] +impl VerifyConnector for connector::Stripe { + async fn handle_payment_error_response( + connector: &(dyn types::api::Connector + Sync), + error_response: types::Response, + ) -> errors::RouterResponse<()> + where + dyn types::api::Connector + Sync: ConnectorIntegration, + { + let error = connector + .get_error_response(error_response) + .change_context(errors::ApiErrorResponse::InternalServerError)?; + match (env::which(), error.code.as_str()) { + // In situations where an attempt is made to process a payment using a + // Stripe production key along with a test card (which verify_connector is using), + // Stripe will respond with a "card_declined" error. In production, + // when this scenario occurs we will send back an "Ok" response. + (env::Env::Production, "card_declined") => Ok(services::ApplicationResponse::StatusOk), + _ => Err(errors::ApiErrorResponse::InvalidRequestData { + message: error.reason.unwrap_or(error.message), + }) + .into_report(), + } + } +} diff --git a/crates/router/src/utils.rs b/crates/router/src/utils.rs index c936ee858c17..81968cd9b628 100644 --- a/crates/router/src/utils.rs +++ b/crates/router/src/utils.rs @@ -6,6 +6,8 @@ pub mod ext_traits; pub mod storage_partitioning; #[cfg(feature = "olap")] pub mod user; +#[cfg(feature = "olap")] +pub mod verify_connector; use std::fmt::Debug; diff --git a/crates/router/src/utils/verify_connector.rs b/crates/router/src/utils/verify_connector.rs new file mode 100644 index 000000000000..6ad683d63ba1 --- /dev/null +++ b/crates/router/src/utils/verify_connector.rs @@ -0,0 +1,49 @@ +use api_models::enums::Connector; +use error_stack::{IntoReport, ResultExt}; + +use crate::{core::errors, types::api}; + +pub fn generate_card_from_details( + card_number: String, + card_exp_year: String, + card_exp_month: String, + card_cvv: String, +) -> errors::RouterResult { + Ok(api::Card { + card_number: card_number + .parse() + .into_report() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error while parsing card number")?, + card_issuer: None, + card_cvc: masking::Secret::new(card_cvv), + card_network: None, + card_exp_year: masking::Secret::new(card_exp_year), + card_exp_month: masking::Secret::new(card_exp_month), + card_holder_name: masking::Secret::new("HyperSwitch".to_string()), + nick_name: None, + card_type: None, + card_issuing_country: None, + bank_code: None, + }) +} + +pub fn get_test_card_details(connector_name: Connector) -> errors::RouterResult> { + match connector_name { + Connector::Stripe => Some(generate_card_from_details( + "4242424242424242".to_string(), + "2025".to_string(), + "12".to_string(), + "100".to_string(), + )) + .transpose(), + Connector::Paypal => Some(generate_card_from_details( + "4111111111111111".to_string(), + "2025".to_string(), + "02".to_string(), + "123".to_string(), + )) + .transpose(), + _ => Ok(None), + } +} diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 2a174f42eb63..c254f89b4eef 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -259,6 +259,8 @@ pub enum Flow { DecisionManagerRetrieveConfig, /// Change password flow ChangePassword, + /// Payment Connector Verify + VerifyPaymentConnector, } ///