From 41556baed98c59373e0a053c023c32f2f7346b51 Mon Sep 17 00:00:00 2001 From: Sai Harsha Vardhan <56996463+sai-harsha-vardhan@users.noreply.github.com> Date: Sat, 9 Mar 2024 12:46:09 +0530 Subject: [PATCH] feat(router): add payments authentication api flow (#3996) Co-authored-by: hrithikesh026 Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> Co-authored-by: Hrithikesh <61539176+hrithikesh026@users.noreply.github.com> --- crates/openapi/src/openapi.rs | 1 + crates/openapi/src/routes/payments.rs | 20 ++ crates/router/src/core/payments.rs | 202 ++++++++++++++++++++- crates/router/src/core/payments/helpers.rs | 131 +++++++++++++ crates/router/src/routes/app.rs | 3 + crates/router/src/routes/payments.rs | 68 +++++++ openapi/openapi_spec.json | 51 ++++++ 7 files changed, 475 insertions(+), 1 deletion(-) diff --git a/crates/openapi/src/openapi.rs b/crates/openapi/src/openapi.rs index 65c4619344..0ac78ef33d 100644 --- a/crates/openapi/src/openapi.rs +++ b/crates/openapi/src/openapi.rs @@ -78,6 +78,7 @@ Never share your secret api keys. Keep them guarded and secure. routes::payments::payments_list, routes::payments::payments_incremental_authorization, routes::payment_link::payment_link_retrieve, + routes::payments::payments_external_authentication, // Routes for refunds routes::refunds::refunds_create, diff --git a/crates/openapi/src/routes/payments.rs b/crates/openapi/src/routes/payments.rs index b6c83d5aae..12fc7778a3 100644 --- a/crates/openapi/src/routes/payments.rs +++ b/crates/openapi/src/routes/payments.rs @@ -450,3 +450,23 @@ pub fn payments_list() {} security(("api_key" = [])) )] pub fn payments_incremental_authorization() {} + +/// Payments - External 3DS Authentication +/// +/// External 3DS Authentication is performed and returns the AuthenticationResponse +#[utoipa::path( + post, + path = "/payments/{payment_id}/3ds/authentication", + request_body=PaymentsExternalAuthenticationRequest, + params( + ("payment_id" = String, Path, description = "The identifier for payment") + ), + responses( + (status = 200, description = "Authentication created", body = PaymentsExternalAuthenticationResponse), + (status = 400, description = "Missing mandatory fields") + ), + tag = "Payments", + operation_id = "Initiate external authentication for a Payment", + security(("publishable_key" = [])) +)] +pub fn payments_external_authentication() {} diff --git a/crates/router/src/core/payments.rs b/crates/router/src/core/payments.rs index 9b06e657f2..52240bbf39 100644 --- a/crates/router/src/core/payments.rs +++ b/crates/router/src/core/payments.rs @@ -49,6 +49,7 @@ use crate::core::fraud_check as frm_core; use crate::{ configs::settings::{ApplePayPreDecryptFlow, PaymentMethodTypeTokenFilter}, core::{ + authentication as authentication_core, errors::{self, CustomResult, RouterResponse, RouterResult}, payment_methods::PaymentMethodRetrieve, utils, @@ -59,10 +60,11 @@ use crate::{ services::{self, api::Authenticate}, types::{ self as router_types, - api::{self, ConnectorCallType}, + api::{self, authentication, ConnectorCallType}, domain, storage::{self, enums as storage_enums, payment_attempt::PaymentAttemptExt}, transformers::{ForeignInto, ForeignTryInto}, + BrowserInformation, }, utils::{ add_apple_pay_flow_metrics, add_connector_http_status_code_metrics, Encode, OptionExt, @@ -3031,3 +3033,201 @@ where Ok(ConnectorCallType::Retryable(connector_data)) } + +#[instrument(skip_all)] +pub async fn payment_external_authentication( + state: AppState, + merchant_account: domain::MerchantAccount, + key_store: domain::MerchantKeyStore, + req: api_models::payments::PaymentsExternalAuthenticationRequest, +) -> RouterResponse { + let db = &*state.store; + let merchant_id = &merchant_account.merchant_id; + let storage_scheme = merchant_account.storage_scheme; + let payment_id = req.payment_id; + let payment_intent = db + .find_payment_intent_by_payment_id_merchant_id(&payment_id, merchant_id, storage_scheme) + .await + .to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?; + let attempt_id = payment_intent.active_attempt.get_id().clone(); + let payment_attempt = db + .find_payment_attempt_by_payment_id_merchant_id_attempt_id( + &payment_intent.payment_id, + merchant_id, + &attempt_id.clone(), + storage_scheme, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::PaymentNotFound)?; + if payment_attempt.external_three_ds_authentication_attempted != Some(true) { + Err(errors::ApiErrorResponse::PreconditionFailed { + message: + "You cannot authenticate this payment because payment_attempt.external_three_ds_authentication_attempted is false".to_owned(), + })? + } + helpers::validate_payment_status_against_allowed_statuses( + &payment_intent.status, + &[storage_enums::IntentStatus::RequiresCustomerAction], + "authenticate", + )?; + let optional_customer = match &payment_intent.customer_id { + Some(customer_id) => Some( + state + .store + .find_customer_by_customer_id_merchant_id( + customer_id, + &merchant_account.merchant_id, + &key_store, + ) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable_lazy(|| { + format!("error while finding customer with customer_id {customer_id}") + })?, + ), + None => None, + }; + let profile_id = payment_intent + .profile_id + .as_ref() + .get_required_value("profile_id") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("'profile_id' not set in payment intent")?; + let currency = payment_attempt.currency.get_required_value("currency")?; + let amount = payment_attempt.get_total_amount().into(); + let shipping_address = helpers::create_or_find_address_for_payment_by_request( + db, + None, + payment_intent.shipping_address_id.as_deref(), + merchant_id, + payment_intent.customer_id.as_ref(), + &key_store, + &payment_intent.payment_id, + storage_scheme, + ) + .await?; + let billing_address = helpers::create_or_find_address_for_payment_by_request( + db, + None, + payment_intent.billing_address_id.as_deref(), + merchant_id, + payment_intent.customer_id.as_ref(), + &key_store, + &payment_intent.payment_id, + storage_scheme, + ) + .await?; + let authentication_connector = payment_attempt + .authentication_connector + .clone() + .ok_or(errors::ApiErrorResponse::InternalServerError) + .into_report() + .attach_printable("authentication_connector not found in payment_attempt")?; + let merchant_connector_account = helpers::get_merchant_connector_account( + &state, + merchant_id, + None, + &key_store, + profile_id, + authentication_connector.as_str(), + None, + ) + .await?; + let authentication = db + .find_authentication_by_merchant_id_authentication_id( + merchant_id.to_string(), + payment_attempt + .authentication_id + .clone() + .ok_or(errors::ApiErrorResponse::InternalServerError) + .into_report() + .attach_printable("missing authentication_id in payment_attempt")?, + ) + .await + .to_not_found_response(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error while fetching authentication record")?; + let authentication_data: AuthenticationData = authentication + .authentication_data + .clone() + .parse_value("authentication data") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Error while parsing authentication_data")?; + let payment_method_details = helpers::get_payment_method_details_from_payment_token( + &state, + &payment_attempt, + &payment_intent, + &key_store, + ) + .await? + .ok_or(errors::ApiErrorResponse::InternalServerError) + .into_report() + .attach_printable("missing payment_method_details")?; + let browser_info: Option = payment_attempt + .browser_info + .clone() + .map(|browser_information| browser_information.parse_value("BrowserInformation")) + .transpose() + .change_context(errors::ApiErrorResponse::InvalidDataValue { + field_name: "browser_info", + })?; + let payment_connector_name = payment_attempt + .connector + .as_ref() + .ok_or(errors::ApiErrorResponse::InternalServerError) + .into_report() + .attach_printable("missing connector in payment_attempt")?; + let return_url = Some(helpers::create_authorize_url( + &state.conf.server.base_url, + &payment_attempt.clone(), + payment_connector_name, + )); + + let business_profile = state + .store + .find_business_profile_by_profile_id(profile_id) + .await + .change_context(errors::ApiErrorResponse::BusinessProfileNotFound { + id: profile_id.to_string(), + })?; + + let authentication_response = authentication_core::perform_authentication( + &state, + authentication_connector, + payment_method_details.0, + payment_method_details.1, + billing_address + .as_ref() + .map(|address| address.into()) + .ok_or(errors::ApiErrorResponse::MissingRequiredField { + field_name: "billing_address", + })?, + shipping_address.as_ref().map(|address| address.into()), + browser_info, + business_profile, + merchant_connector_account, + amount, + Some(currency), + authentication::MessageCategory::Payment, + req.device_channel, + (authentication_data, authentication), + return_url, + req.sdk_information, + req.threeds_method_comp_ind, + optional_customer.and_then(|customer| customer.email.map(common_utils::pii::Email::from)), + ) + .await?; + Ok(services::ApplicationResponse::Json( + api_models::payments::PaymentsExternalAuthenticationResponse { + transaction_status: authentication_response.trans_status, + acs_url: authentication_response + .acs_url + .as_ref() + .map(ToString::to_string), + challenge_request: authentication_response.challenge_request, + acs_reference_number: authentication_response.acs_reference_number, + acs_trans_id: authentication_response.acs_trans_id, + three_dsserver_trans_id: authentication_response.three_dsserver_trans_id, + acs_signed_content: authentication_response.acs_signed_content, + }, + )) +} diff --git a/crates/router/src/core/payments/helpers.rs b/crates/router/src/core/payments/helpers.rs index 2bcd317baf..a60fae4f57 100644 --- a/crates/router/src/core/payments/helpers.rs +++ b/crates/router/src/core/payments/helpers.rs @@ -36,6 +36,7 @@ use crate::{ errors::{self, CustomResult, RouterResult, StorageErrorExt}, payment_methods::{cards, vault, PaymentMethodRetrieve}, payments, + pm_auth::retrieve_payment_method_from_auth_service, }, db::StorageInterface, routes::{metrics, payment_methods, AppState}, @@ -895,6 +896,17 @@ pub fn create_redirect_url( ) + creds_identifier_path.as_ref() } +pub fn create_authorize_url( + router_base_url: &String, + payment_attempt: &PaymentAttempt, + connector_name: &String, +) -> String { + format!( + "{}/payments/{}/{}/authorize/{}", + router_base_url, payment_attempt.payment_id, payment_attempt.merchant_id, connector_name + ) +} + pub fn create_webhook_url( router_base_url: &String, merchant_id: &String, @@ -3860,6 +3872,125 @@ pub fn validate_session_expiry(session_expiry: u32) -> Result<(), errors::ApiErr } } +pub async fn get_payment_method_details_from_payment_token( + state: &AppState, + payment_attempt: &PaymentAttempt, + payment_intent: &PaymentIntent, + key_store: &domain::MerchantKeyStore, +) -> RouterResult> { + let hyperswitch_token = if let Some(token) = payment_attempt.payment_token.clone() { + let redis_conn = state + .store + .get_redis_conn() + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to get redis connection")?; + let key = format!( + "pm_token_{}_{}_hyperswitch", + token, + payment_attempt + .payment_method + .to_owned() + .get_required_value("payment_method")?, + ); + let token_data_string = redis_conn + .get_key::>(&key) + .await + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("Failed to fetch the token from redis")? + .ok_or(error_stack::Report::new( + errors::ApiErrorResponse::UnprocessableEntity { + message: "Token is invalid or expired".to_owned(), + }, + ))?; + let token_data_result = token_data_string + .clone() + .parse_struct("PaymentTokenData") + .change_context(errors::ApiErrorResponse::InternalServerError) + .attach_printable("failed to deserialize hyperswitch token data"); + let token_data = match token_data_result { + Ok(data) => data, + Err(e) => { + // The purpose of this logic is backwards compatibility to support tokens + // in redis that might be following the old format. + if token_data_string.starts_with('{') { + return Err(e); + } else { + storage::PaymentTokenData::temporary_generic(token_data_string) + } + } + }; + Some(token_data) + } else { + None + }; + let token = hyperswitch_token + .ok_or(errors::ApiErrorResponse::InternalServerError) + .into_report() + .attach_printable("missing hyperswitch_token")?; + match token { + storage::PaymentTokenData::TemporaryGeneric(generic_token) => { + retrieve_payment_method_with_temporary_token( + state, + &generic_token.token, + payment_intent, + key_store, + None, + ) + .await + } + + storage::PaymentTokenData::Temporary(generic_token) => { + retrieve_payment_method_with_temporary_token( + state, + &generic_token.token, + payment_intent, + key_store, + None, + ) + .await + } + + storage::PaymentTokenData::Permanent(card_token) => retrieve_card_with_permanent_token( + state, + &card_token.token, + card_token + .payment_method_id + .as_ref() + .unwrap_or(&card_token.token), + payment_intent, + None, + ) + .await + .map(|card| Some((card, enums::PaymentMethod::Card))), + + storage::PaymentTokenData::PermanentCard(card_token) => retrieve_card_with_permanent_token( + state, + &card_token.token, + card_token + .payment_method_id + .as_ref() + .unwrap_or(&card_token.token), + payment_intent, + None, + ) + .await + .map(|card| Some((card, enums::PaymentMethod::Card))), + + storage::PaymentTokenData::AuthBankDebit(auth_token) => { + retrieve_payment_method_from_auth_service( + state, + key_store, + &auth_token, + payment_intent, + &None, + ) + .await + } + + storage::PaymentTokenData::WalletToken(_) => Ok(None), + } +} + // This function validates the mandate_data with its setup_future_usage pub fn validate_mandate_data_and_future_usage( setup_future_usages: Option, diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 1d3c49a661..0e43f9c81d 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -364,6 +364,9 @@ impl Payments { ) .service( web::resource("/{payment_id}/incremental_authorization").route(web::post().to(payments_incremental_authorization)), + ) + .service( + web::resource("/{payment_id}/3ds/authentication").route(web::post().to(payments_external_authentication)), ); } route diff --git a/crates/router/src/routes/payments.rs b/crates/router/src/routes/payments.rs index 24849d828e..c83e52199e 100644 --- a/crates/router/src/routes/payments.rs +++ b/crates/router/src/routes/payments.rs @@ -1208,6 +1208,58 @@ pub async fn payments_incremental_authorization( .await } +/// Payments - External 3DS Authentication +/// +/// External 3DS Authentication is performed and returns the AuthenticationResponse +#[utoipa::path( + post, + path = "/payments/{payment_id}/3ds/authentication", + request_body=PaymentsExternalAuthenticationRequest, + params( + ("payment_id" = String, Path, description = "The identifier for payment") + ), + responses( + (status = 200, description = "Authentication created"), + (status = 400, description = "Missing mandatory fields") + ), + tag = "Payments", + operation_id = "Initiate external authentication for a Payment", + security(("api_key" = [])) +)] +#[instrument(skip_all, fields(flow = ?Flow::PaymentsExternalAuthentication, payment_id))] +pub async fn payments_external_authentication( + state: web::Data, + req: actix_web::HttpRequest, + json_payload: web::Json, + path: web::Path, +) -> impl Responder { + let flow = Flow::PaymentsExternalAuthentication; + let mut payload = json_payload.into_inner(); + let payment_id = path.into_inner(); + + tracing::Span::current().record("payment_id", &payment_id); + + payload.payment_id = payment_id; + let locking_action = payload.get_locking_input(flow.clone()); + Box::pin(api::server_wrap( + flow, + state, + &req, + payload, + |state, auth, req| { + payments::payment_external_authentication( + state, + auth.merchant_account, + auth.key_store, + req, + ) + }, + &auth::PublishableKeyAuth, + locking_action, + )) + .await +} + pub fn get_or_generate_payment_id( payload: &mut payment_types::PaymentsRequest, ) -> errors::RouterResult<()> { @@ -1409,3 +1461,19 @@ impl GetLockingInput for payment_types::PaymentsIncrementalAuthorizationRequest } } } + +impl GetLockingInput for payment_types::PaymentsExternalAuthenticationRequest { + fn get_locking_input(&self, flow: F) -> api_locking::LockAction + where + F: types::FlowMetric, + lock_utils::ApiIdentifier: From, + { + api_locking::LockAction::Hold { + input: api_locking::LockingInput { + unique_locking_key: self.payment_id.to_owned(), + api_identifier: lock_utils::ApiIdentifier::from(flow), + override_lock_retries: None, + }, + } + } +} diff --git a/openapi/openapi_spec.json b/openapi/openapi_spec.json index 781e121a5b..4949d31b53 100644 --- a/openapi/openapi_spec.json +++ b/openapi/openapi_spec.json @@ -2959,6 +2959,57 @@ ] } }, + "/payments/{payment_id}/3ds/authentication": { + "post": { + "tags": [ + "Payments" + ], + "summary": "Payments - External 3DS Authentication", + "description": "Payments - External 3DS Authentication\n\nExternal 3DS Authentication is performed and returns the AuthenticationResponse", + "operationId": "Initiate external authentication for a Payment", + "parameters": [ + { + "name": "payment_id", + "in": "path", + "description": "The identifier for payment", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentsExternalAuthenticationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Authentication created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentsExternalAuthenticationResponse" + } + } + } + }, + "400": { + "description": "Missing mandatory fields" + } + }, + "security": [ + { + "publishable_key": [] + } + ] + } + }, "/payments/{payment_id}/cancel": { "post": { "tags": [