feat(core): Add ability to verify connector credentials before integrating the connector (#2986)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Mani Chandra
2023-11-30 13:06:35 +05:30
committed by GitHub
parent 44b1f4949e
commit 39f255b4b2
18 changed files with 552 additions and 2 deletions

View File

@ -1,3 +1,5 @@
use std::collections::HashMap;
use common_utils::{ use common_utils::{
crypto::{Encryptable, OptionalEncryptableName}, crypto::{Encryptable, OptionalEncryptableName},
pii, pii,
@ -614,6 +616,36 @@ pub struct MerchantConnectorCreate {
pub status: Option<api_enums::ConnectorStatus>, pub status: Option<api_enums::ConnectorStatus>,
} }
// Different patterns of authentication.
#[derive(Default, Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(tag = "auth_type")]
pub enum ConnectorAuthType {
TemporaryAuth,
HeaderKey {
api_key: Secret<String>,
},
BodyKey {
api_key: Secret<String>,
key1: Secret<String>,
},
SignatureKey {
api_key: Secret<String>,
key1: Secret<String>,
api_secret: Secret<String>,
},
MultiAuthKey {
api_key: Secret<String>,
key1: Secret<String>,
api_secret: Secret<String>,
key2: Secret<String>,
},
CurrencyAuthKey {
auth_key_map: HashMap<common_enums::Currency, pii::SecretSerdeValue>,
},
#[default]
NoKey,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct MerchantConnectorWebhookDetails { pub struct MerchantConnectorWebhookDetails {

View File

@ -27,4 +27,5 @@ pub mod routing;
pub mod surcharge_decision_configs; pub mod surcharge_decision_configs;
pub mod user; pub mod user;
pub mod verifications; pub mod verifications;
pub mod verify_connector;
pub mod webhooks; pub mod webhooks;

View File

@ -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);

View File

@ -65,3 +65,8 @@ pub const JWT_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24 * 2; // 2 days
#[cfg(feature = "email")] #[cfg(feature = "email")]
pub const EMAIL_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24; // 1 day pub const EMAIL_TOKEN_TIME_IN_SECS: u64 = 60 * 60 * 24; // 1 day
pub const ROLE_ID_ORGANIZATION_ADMIN: &str = "org_admin"; 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";

View File

@ -28,4 +28,6 @@ pub mod user;
pub mod utils; pub mod utils;
#[cfg(all(feature = "olap", feature = "kms"))] #[cfg(all(feature = "olap", feature = "kms"))]
pub mod verification; pub mod verification;
#[cfg(feature = "olap")]
pub mod verify_connector;
pub mod webhooks; pub mod webhooks;

View File

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

View File

@ -29,6 +29,8 @@ pub mod routing;
pub mod user; pub mod user;
#[cfg(all(feature = "olap", feature = "kms"))] #[cfg(all(feature = "olap", feature = "kms"))]
pub mod verification; pub mod verification;
#[cfg(feature = "olap")]
pub mod verify_connector;
pub mod webhooks; pub mod webhooks;
pub mod locker_migration; pub mod locker_migration;

View File

@ -30,6 +30,8 @@ use super::{cache::*, health::*};
use super::{configs::*, customers::*, mandates::*, payments::*, refunds::*}; use super::{configs::*, customers::*, mandates::*, payments::*, refunds::*};
#[cfg(feature = "oltp")] #[cfg(feature = "oltp")]
use super::{ephemeral_key::*, payment_methods::*, webhooks::*}; use super::{ephemeral_key::*, payment_methods::*, webhooks::*};
#[cfg(feature = "olap")]
use crate::routes::verify_connector::payment_connector_verify;
pub use crate::{ pub use crate::{
configs::settings, configs::settings,
db::{StorageImpl, StorageInterface}, db::{StorageImpl, StorageInterface},
@ -548,6 +550,10 @@ impl MerchantConnectorAccount {
use super::admin::*; use super::admin::*;
route = route route = route
.service(
web::resource("/connectors/verify")
.route(web::post().to(payment_connector_verify)),
)
.service( .service(
web::resource("/{merchant_id}/connectors") web::resource("/{merchant_id}/connectors")
.route(web::post().to(payment_connector_create)) .route(web::post().to(payment_connector_create))

View File

@ -147,7 +147,9 @@ impl From<Flow> for ApiIdentifier {
| Flow::GsmRuleUpdate | Flow::GsmRuleUpdate
| Flow::GsmRuleDelete => Self::Gsm, | Flow::GsmRuleDelete => Self::Gsm,
Flow::UserConnectAccount | Flow::ChangePassword => Self::User, Flow::UserConnectAccount | Flow::ChangePassword | Flow::VerifyPaymentConnector => {
Self::User
}
} }
} }
} }

View File

@ -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<AppState>,
req: HttpRequest,
json_payload: web::Json<VerifyConnectorRequest>,
) -> 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
}

View File

@ -33,7 +33,7 @@ use crate::{
payments::{PaymentData, RecurringMandatePaymentData}, payments::{PaymentData, RecurringMandatePaymentData},
}, },
services, services,
types::storage::payment_attempt::PaymentAttemptExt, types::{storage::payment_attempt::PaymentAttemptExt, transformers::ForeignFrom},
utils::OptionExt, utils::OptionExt,
}; };
@ -942,6 +942,78 @@ pub enum ConnectorAuthType {
NoKey, NoKey,
} }
impl From<api_models::admin::ConnectorAuthType> 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<ConnectorAuthType> 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)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConnectorsList { pub struct ConnectorsList {
pub connectors: Vec<String>, pub connectors: Vec<String>,

View File

@ -13,6 +13,8 @@ pub mod payments;
pub mod payouts; pub mod payouts;
pub mod refunds; pub mod refunds;
pub mod routing; pub mod routing;
#[cfg(feature = "olap")]
pub mod verify_connector;
pub mod webhooks; pub mod webhooks;
use std::{fmt::Debug, str::FromStr}; use std::{fmt::Debug, str::FromStr};

View File

@ -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<F, R1, R2>(
&self,
request_data: R1,
access_token: Option<types::AccessToken>,
) -> types::RouterData<F, R1, R2> {
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<Option<types::AccessToken>, 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<F, R1, R2>(
connector: &(dyn types::api::Connector + Sync),
error_response: types::Response,
) -> errors::RouterResponse<()>
where
dyn types::api::Connector + Sync: ConnectorIntegration<F, R1, R2>,
{
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<F, R1, R2>(
connector: &(dyn types::api::Connector + Sync),
error_response: types::Response,
) -> errors::RouterResult<Option<types::AccessToken>>
where
dyn types::api::Connector + Sync: ConnectorIntegration<F, R1, R2>,
{
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()
}
}

View File

@ -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<Option<types::AccessToken>, 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
}
}
}
}

View File

@ -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<F, R1, R2>(
connector: &(dyn types::api::Connector + Sync),
error_response: types::Response,
) -> errors::RouterResponse<()>
where
dyn types::api::Connector + Sync: ConnectorIntegration<F, R1, R2>,
{
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(),
}
}
}

View File

@ -6,6 +6,8 @@ pub mod ext_traits;
pub mod storage_partitioning; pub mod storage_partitioning;
#[cfg(feature = "olap")] #[cfg(feature = "olap")]
pub mod user; pub mod user;
#[cfg(feature = "olap")]
pub mod verify_connector;
use std::fmt::Debug; use std::fmt::Debug;

View File

@ -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<api::Card> {
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<Option<api::Card>> {
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),
}
}

View File

@ -259,6 +259,8 @@ pub enum Flow {
DecisionManagerRetrieveConfig, DecisionManagerRetrieveConfig,
/// Change password flow /// Change password flow
ChangePassword, ChangePassword,
/// Payment Connector Verify
VerifyPaymentConnector,
} }
/// ///