feat(external-3ds): Netcetera external 3DS authentication over VGS external vault (v1)#13143
feat(external-3ds): Netcetera external 3DS authentication over VGS external vault (v1)#13143ShankarSinghC wants to merge 29 commits into
Conversation
…ternal vault (v1) Adds external-3DS (Netcetera) authentication for payments whose card lives in a VGS external vault (Hyperswitch holds only VGS aliases, never the PAN). The card proxy (VGS alias) rides the UCS injector, which reveals the PAN and presents the Netcetera mTLS client cert on the VGS outbound route. Flow (v1 external-vault proxy): confirm -> pre-auth (versioning/PReq) -> ThreeDsInvoke -> /3ds/authentication (AReq) -> frictionless (ARes CAVV) or challenge (acs_url/creq) -> re-confirm resume -> post-authenticate -> authorize. Frictionless path is validated end-to-end (charged): the ARes CAVV/ECI/xid reach the PSP authorize via the external-vault proxy. Authorize router_data is rebuilt after the resume sets payment_data.authentication so the 3DS proof is present. Challenge path scaffolding is in place (trans_status C -> acs_url/creq, RReq via the VGS mTLS proxy). Webhook-driven completion for pull_mechanism=false merchants is the remaining work (Stage 2). - consts: UCS_AUTH_NO_KEY for the credential-less Netcetera UCS route - unified_connector_service/connector_config: omit x-connector-config for Netcetera (mTLS on VGS route, merchant fields via connector_feature_data) - payments: external-vault pre-auth/AReq/resume orchestration + authorize rebuild - authorize_flow / ucs transformers: post-authenticate carries card_proxy so the RReq routes through the VGS proxy - payment_confirm_external_vault_proxy: allow RequiresCustomerAction resume; persist confirm browser_info for the AReq browser channel Excludes local-only config/development.toml (test env + vault keys). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…k (Stage 2) Wires the completed-challenge (pull_mechanism=false) path for external-3DS over VGS external vault to "charged". After the cardholder finishes the ACS challenge, Netcetera pushes the results (RRes) to the AReq-provided notification URL; this lets our own webhook drive the auth record to Success and the SDK re-confirm authorize with the resulting CAVV. Three targeted fixes (validated end-to-end: challenge -> RRes webhook -> auth Success + CAVV vaulted -> re-confirm -> checkout charged): - payments.rs (pre-auth): the incoming netcetera results webhook is keyed by `connector_authentication_id` (get_webhook_object_reference_id -> ConnectorAuthenticationId(threeDSServerTransID)), but netcetera reports its id on the versioning response as `threeds_server_transaction_id` (no distinct `transaction_id`), so the record persisted `connector_authentication_id = ""` and the webhook lookup missed. Fall back to the threeDSServerTransID. - webhooks/incoming.rs (external_authentication_incoming_webhook_flow): for an external-vault profile, skip the native auto-authorize. The native path drives `payments::PaymentConfirm`, which needs a card/payment_method; for external vault the PAN lives behind a VGS alias re-fetched only inside the external-vault-proxy confirm op (a minimal PaymentConfirm errors IR_04). The auth record + vaulted CAVV are already persisted, so completion happens on the SDK re-confirm. - payments.rs (post-authenticate resume): resolve the cavv from connector_metadata (frictionless ARes stash) / cavv column, then fall back to the temp-locker vault (get_tokenized_data). A completed challenge delivers the cavv via the RRes webhook, which vaults it keyed by authentication_id rather than writing connector_metadata; the vault fallback lets that path resolve. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…low parity Makes external-vault external-3DS match normal-payments completion for pull_mechanism=false, replacing the earlier SDK-re-confirm bridge. - webhooks/incoming.rs: on the pull=false + Challenge + ARes results webhook, the external-vault branch now DRIVES the authorize itself via external_vault_proxy_for_payments_core(PaymentExternalVaultProxyConfirm) with a synthetic VaultCardTokenData request keyed by the stored payment_token (re-fetches the VGS alias, empty CVC, authorizes with the vaulted CAVV). No SDK call / no re-confirm required. Validated end-to-end: RRes webhook alone -> checkout charged. - payments.rs (PaymentAuthenticateCompleteAuthorize::call_payment_flow, the three_ds_authorize_url redirect flow): resolve the real auth-connector (netcetera) MCA for external vault instead of the placeholder unified_authentication_service (which has no MCA -> HE_02), mirroring the /3ds/authentication prologue; and never take the standard-PaymentConfirm authorize branch for external vault (it has no VGS proxy). The SDK's post-challenge three_ds_authorize_url call now reports status (PSync) — succeeded after the webhook lands — matching normal pull=false+Challenge, instead of erroring. The Succeeded status guard + per-payment API lock prevent any double authorize, and for external vault the standard path structurally cannot charge a VGS-alias card. Known edge: an SDK three_ds_authorize_url call that arrives STRICTLY BEFORE the RRes webhook errors IR_04 (the external-vault attempt has no payment_method persisted until authorize), where normal payments return pending; the webhook still completes the payment. Fix (persist payment_method on the challenge-halt) tracked separately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…horize_url Closes the external-vault-specific divergence where an SDK three_ds_authorize_url call that lands BEFORE the RRes webhook errored, instead of returning pending like normal payments (validated: [A] before-webhook -> requires_customer_action, [B] webhook -> charged, [C] after-webhook -> succeeded). Two causes, both from the proxy confirm's HaltForSdkChallenge early return skipping the ConfirmUpdate that a normal confirm runs: - payment_attempt AuthenticationUpdate: carry payment_method / payment_method_type (domain + diesel variants + mappings). The challenge-halt persist (persist_external_vault_pre_auth_challenge...) now sets them from payment_data, so the attempt has payment_method like a normal confirm. Without it a PSync in the redirect flow errored IR_04 "Missing required param: payment_method". None for the other AuthenticationUpdate callers leaves the columns unchanged (AsChangeset skips None). - PaymentAuthenticateCompleteAuthorize redirect PSync: for external vault report the local status only (force_sync = false). A force_sync on a still-challenge-pending payment (no connector transaction yet) called the PSP sync with nothing to sync and failed "Something went wrong", stranding the payment. The RRes webhook owns the authorize; the SDK's call only needs the status. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The external-vault confirm vaults the card and the `{{$card_exp_year}}` injector
template later resolves to the stored year. A 4-digit year (as the web SDK / VGS
Collect supplies) then produces a 6-character YYYYMM `cardExpiryDate`, which fails
the 3DS AReq validation ("cardExpiryDate: string has wrong length. Expected 4 but
got 6"). Normalize to the last two digits before vaulting so the template resolves
to YY; vault template tokens ({{...}}) are left untouched.
Surfaced by an end-to-end run through the Hyperswitch web SDK (curl tests used a
2-digit year and so never hit it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ig, status gate - ExternalVaultCard.card_cvc -> Option<Secret<String>>: model CVC as optional (present on the SDK-driven confirm, absent on a CAVV-authenticated re-authorize) instead of forwarding empty-string placeholders; the connector now omits the field. Aligns with the CardToken optional-CVC model. Updates every construction and read site (domain struct, From impls, UCS->gRPC transformer, the op, and the webhook/post-auth callers). - unified_connector_service/connector_config: add a Netcetera variant to ConnectorSpecificConfig plus a match arm, removing the special-case early return in build_connector_config_header (UCS ignores it via the x-auth: no-key shortcut). - payment_confirm_external_vault_proxy: gate RequiresCustomerAction by payment source (Webhook / ExternalAuthenticator, plus Review), mirroring the standard confirm operation. - webhooks/incoming: document the external-vault setup-mandate gap as a TODO. - clean up two unnecessary-qualification lints. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…completion - Adapt the external-vault auth-record fetch/update to main's renamed processor-aware functions (find/update_authentication_by_processor_merchant_id_ authentication_id) — the cherry-picked feature code still called the old names, which main removed (this is why CI was failing to build). - Pass processor.get_account().get_id() (a MerchantId) to build_unified_connector_service_auth_metadata, matching its current signature. - Add the frictionless external-vault /authorize completion in call_payment_flow: the SDK's three_ds_authorize_url drives external_vault_proxy_for_payments_core so a frictionless payment charges instead of hanging at RequiresCustomerAction (challenge still completes via the pull=false RRes webhook). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove ~510 lines of oversized explanatory comments across the external-3ds files, consolidate a masking import, and drop a redundant unit-test module. No functional change — the card_exp_year YY-normalization logic is preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fields like normal payments
Make the Netcetera-over-VGS external-vault 3DS flow work end-to-end through the real
web SDK (one-time + save-card, frictionless + challenge), and stop carrying per-payment
/ per-connector values on the netcetera MCA — infer them the way inline payments do.
- create_payment_method (external-vault proxy confirm): also create the proxy-card payment
method (and thus mint the re-fetchable alias payment_token) when external 3DS is requested,
not only when customer_acceptance is present. A one-time (no-save-card) payment now carries a
payment_token for the multi-leg 3DS flow. Fixes the SDK's /3ds/authentication HE_02
("connector name unified_authentication_service does not exist") on one-time payments.
- call_payment_flow (/authorize/{connector}, three_ds_authorize_url): for external-vault, drive
the external-vault-proxy authorize on frictionless (was PSync -> stuck at
requires_customer_action), and fall through to the status-only PSync on challenge that the
pull=false RRes webhook already completed (was re-confirm -> IR_16 "status succeeded").
- Inject the AReq's per-payment/connector values into the netcetera connector_feature_data so
they are derived from the payment / PSP metadata rather than the netcetera MCA:
* force_3ds_challenge -> payment intent, else business-profile default
* results_response_notification_url -> create_webhook_url (system-constructed pull=false RRes target)
* notification_url -> create_authorize_url (three_ds_authorize_url)
* acquirer_bin/merchant_id/country_code -> resolved from the PSP (checkout) MCA metadata
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vault challenge On an external-vault 3DS auth failure (RRes transStatus=N, or the cardholder cancelling the challenge), fail_external_vault_post_authenticate marked only the authentication record Failed and returned a PaymentAuthenticationFailed error, so the payment stayed stuck at RequiresCustomerAction (webhook 400, SDK polling forever, "requires_customer_action" shown to the shopper). Now it also transitions the payment to a terminal Failed state (attempt -> Failure with EXTERNAL_AUTHENTICATION_FAILURE, intent -> Failed) and returns a new HaltWithAuthenticationFailure decision so the failed payment_data flows to the response builder instead of erroring. This mirrors how normal payments end an auth failure (status_handler_for_authentication_results): the webhook returns 200, poll-completion + the outgoing merchant webhook fire, and the SDK renders "payment failed" and stops polling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… plaintext The external-vault frictionless AReq leg stashed the raw cavv as plaintext in the authentication record's `connector_metadata` (`external_vault_frictionless_cavv`), while the challenge path already vaults the cavv (encrypted, keyed by authentication_id) via the results webhook. Vault the frictionless cavv the same way and set `connector_metadata: None`; the post-authenticate resume already falls back to the vault, so drop its `connector_metadata` read. The cavv is now encrypted at rest on both paths, with nothing plaintext in `connector_metadata` or the `cavv` column. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…VGS external vault Support the external-3DS-over-VGS-external-vault flow (v1) for platform/ connected merchants, where the vault, customer, and proxy PM live on the provider (platform) while the payment executes on the processor (connected): - Resolve provider-owned resources under the provider: customer + vault MCA lookups, and the v2 provider profile (resolve_provider_profile). Add Platform::get_provider_as_processor. - Carry payment_method_type on the external-vault-proxy confirm (frictionless /authorize + challenge webhook) so it does not rely on the resolved PM surfacing it for a connected PM (IR_04). - Fetch the authentication record under the processor merchant in the post-authentication and PSync paths (stored under the connected merchant, not the provider the intent/profile resolves to). - Gate proxy-card PM creation on modular-service eligibility, matching the token fetch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ure_data_from_auth_mca Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ayment_status) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| pub fn get_provider_as_processor(&self) -> Processor { | ||
| Processor::new( | ||
| self.provider.get_account().clone(), | ||
| self.provider.get_key_store().clone(), | ||
| ) | ||
| } |
| force_3ds_challenge: Option<bool>, | ||
| // System-constructed AReq `notification_url` (browser CRes return = `three_ds_authorize_url`), | ||
| // injected into connector_feature_data so it is derived from the payment rather than MCA metadata. | ||
| notification_url: Option<String>, |
There was a problem hiding this comment.
can you convert into url:Url
| if payment_attempt.browser_info.is_none() { | ||
| payment_attempt.browser_info = request.browser_info.clone(); | ||
| } |
| .payment_intent | ||
| .request_external_three_ds_authentication | ||
| == Some(true); | ||
| if payment_data.customer_acceptance.is_none() && !is_external_three_ds_requested { |
There was a problem hiding this comment.
why are we storing card for all external vault use cases
| } | ||
|
|
||
| #[cfg(feature = "v1")] | ||
| fn build_external_vault_pre_auth_authentication_store<F, D>( |
There was a problem hiding this comment.
we already have a create_authentication function can we reuse the existing function and pass extra fields as optional params
| // Resolve the cavv from the `cavv` column, then the temp-locker vault (primary source — both | ||
| // the frictionless AReq leg and the completed challenge vault it encrypted by authentication_id). | ||
| let mut frictionless_cavv = authentication.cavv.clone(); | ||
| if frictionless_cavv.is_none() { |
There was a problem hiding this comment.
can we have a log describing the retrieval tokenise data action
| let response = if is_pull_mechanism_enabled | ||
| // For a challenge with pull=false the RRes webhook drives the authorize, so this call is | ||
| // status-only; frictionless (or pull=true) completes here. | ||
| let authorize_completes_on_this_call = is_pull_mechanism_enabled |
There was a problem hiding this comment.
can we have a better name for this variable
| merchant_id: req.merchant_id, | ||
| param: req.param, | ||
| force_sync: req.force_sync, | ||
| force_sync: !is_external_vault_payment && req.force_sync, |
There was a problem hiding this comment.
why do we need to check !is_external_vault_payment if is_external_vault_payment is true this will anyway not enter this else block
|
|
||
| let processor = platform.get_processor(); | ||
| let key_store = processor.get_key_store(); | ||
| let key_manager_state: common_utils::types::keymanager::KeyManagerState = state.into(); |
There was a problem hiding this comment.
this type caste is not required right
| crate::core::payment_methods::transformers::fetch_payment_method_from_modular_service( | ||
| state, | ||
| platform, | ||
| profile_id, | ||
| &payment_method_id, | ||
| None, | ||
| false, | ||
| ) | ||
| .await |
There was a problem hiding this comment.
we shouldn't be saving permanently in modular Auth service
| )?, | ||
| )); | ||
|
|
||
| let psp_connector_name = payment_attempt |
There was a problem hiding this comment.
why are we fetching psp_connector_name in authentication we should be fetching authentication connector if required anywhere
| let ucs_authentication_data = | ||
| hyperswitch_domain_models::router_request_types::UcsAuthenticationData { | ||
| eci: authentication.eci.clone(), | ||
| cavv: authentication.cavv.clone().map(Secret::new), |
There was a problem hiding this comment.
we shouldn't be storing cavv in authentication table
| @@ -12712,16 +14332,52 @@ pub async fn payment_external_authentication<F: Clone + Sync>( | |||
| id: profile_id.get_string_repr().to_owned(), | |||
| })?; | |||
|
|
|||
| let payment_method_details = helpers::get_payment_method_details_from_payment_token( | |||
| &state, | |||
| &payment_attempt, | |||
| &payment_intent, | |||
| &platform, | |||
| storage_scheme, | |||
| ) | |||
| .await? | |||
| .ok_or(errors::ApiErrorResponse::InternalServerError) | |||
| .attach_printable("missing payment_method_details")?; | |||
| let is_external_vault_payment = business_profile | |||
| .external_vault_details | |||
| .is_external_vault_enabled() | |||
| && payment_attempt.payment_token.is_some(); | |||
|
|
|||
There was a problem hiding this comment.
external_vault_details will be configured at provider level not processor level
| let merchant_connector_account = if is_external_vault_payment { | ||
| resolve_external_vault_authentication_connector( | ||
| &state, | ||
| platform.get_processor(), | ||
| &business_profile, | ||
| ) | ||
| .await? | ||
| .1 | ||
| } else { | ||
| helpers::get_merchant_connector_account( | ||
| &state, | ||
| platform.get_processor(), | ||
| None, | ||
| profile_id, | ||
| authentication_connector.as_str(), | ||
| None, | ||
| ) | ||
| .await? | ||
| }; | ||
|
|
||
| let payment_method_details = if is_external_vault_payment { | ||
| None | ||
| } else { | ||
| Some( | ||
| helpers::get_payment_method_details_from_payment_token( | ||
| &state, | ||
| &payment_attempt, | ||
| &payment_intent, | ||
| &platform, | ||
| storage_scheme, | ||
| ) | ||
| .await? | ||
| .ok_or(errors::ApiErrorResponse::InternalServerError) |
There was a problem hiding this comment.
can we put this evaluation into a single conditional statement
| let payment_method_details = payment_method_details | ||
| .ok_or(errors::ApiErrorResponse::InternalServerError) | ||
| .attach_printable( | ||
| "payment_method_details missing for the legacy authentication path", | ||
| )?; |
There was a problem hiding this comment.
why do we want to raise error here
| // Set the orchestrated 3DS authentication record (carries CAVV/ECI). Used by the | ||
| // external-vault proxy UCS pre-authenticate (frictionless) path. No-op on the v2 data | ||
| // types, which do not yet carry an `authentication` field. | ||
| fn set_authentication( | ||
| &mut self, | ||
| _authentication: Option< | ||
| hyperswitch_domain_models::router_request_types::authentication::AuthenticationStore, | ||
| >, | ||
| ) { | ||
| } |
There was a problem hiding this comment.
we should set authentication carrying CAVV
| self.external_vault_pmd = external_vault_pmd; | ||
| } | ||
|
|
||
| fn set_authentication( |
| let mut metadata_value = metadata.expose(); | ||
| if let Some(obj) = metadata_value.as_object_mut() { | ||
| if let Some(force) = force_3ds_challenge { | ||
| obj.insert( | ||
| "force_3ds_challenge".to_string(), | ||
| serde_json::Value::Bool(force), | ||
| ); | ||
| } | ||
| if let Some(url) = results_response_notification_url { | ||
| obj.insert( | ||
| "results_response_notification_url".to_string(), | ||
| serde_json::Value::String(url), | ||
| ); | ||
| } | ||
| if let Some(url) = notification_url { | ||
| obj.insert( | ||
| "notification_url".to_string(), | ||
| serde_json::Value::String(url), | ||
| ); | ||
| } | ||
| if let Some(serde_json::Value::Object(acquirer)) = acquirer_metadata { | ||
| for (key, value) in acquirer { | ||
| obj.insert(key, value); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
will we be taking this refactor later?? of adding all these fields as a part of Authentication request rather than sending in metadata
|
|
||
| /// Header value indicating that no credentials are required (e.g. external-3DS | ||
| /// over VGS where mTLS is handled on the outbound proxy route, not by UCS). | ||
| pub const UCS_AUTH_NO_KEY: &str = "no-key"; |
There was a problem hiding this comment.
it should not be NO-KEY it will be certficate Auth
… proxy
Restructure the external-vault-proxy 3DS entry point to the same shape the inline
confirm uses (call_external_three_ds_authentication_if_eligible), so review is 1:1:
- get_payment_external_authentication_flow_during_confirm_for_external_proxy returns
the enum PaymentExternalAuthenticationFlowForExternalProxy {PreAuthenticationFlow |
PostAuthenticationFlow{authentication_id}} (None = continue to authorize).
- call_external_three_ds_authentication_if_eligible_for_external_proxy dispatches on it
to perform_pre_authentication_for_external_proxy / perform_post_authentication_for_external_proxy
(the latter renamed from resume_external_vault_post_authenticate_v1).
Was one fn (call_unified_connector_service_pre_authenticate_if_eligible_for_external_vault_proxy_v1)
that fused the pre-auth body with an early-return post-auth branch. Drops the unused
merchant_connector_account param. Behavior-preserving — frictionless charge re-verified.
Also trims verbose comments in the same file.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… update_trackers - No-customer-acceptance external 3DS over the VGS proxy now stashes the vault alias in the temp locker instead of persisting a modular customer PM; fetch_payment_method resolves the TemporaryGeneric token on re-confirm. - Route the pre-auth Halt (challenge / auth-failure) through the operation's update_trackers for parity with the inline flow: persist_/fail_ only mutate payment_data in-memory; decide_ucs sets the attempt Pending so the challenge resume authorizes correctly. - Consolidate operation_core's two connector arms into one shared body (Retryable behavior preserved). - perform_post honors is_pull_mechanism_enabled (skip the UCS pull when pull is disabled or the auth is terminal), matching inline perform_post_authentication. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h-connector UCS gate - read external_vault_details (VGS config) from the provider profile via resolve_provider_profile at the config-read sites (payment gates, vault-MCA fetch, incoming webhook); the modular PM-storage profile stays the processor's - build the authenticate router_data with the resolved auth connector so should_call_unified_connector_service evaluates the connector actually called (Netcetera) - mechanical: drop redundant KeyManagerState annotations, single payment_method_details require on the legacy path, rename authorize_completes_in_this_call, functional browser_info fallback, notification_url as a typed common_utils::types::Url Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| .get_required_value("payment_method_id") | ||
| .attach_printable("could not resolve a payment_method_id from the payment_token")?; | ||
| let pm_with_raw = | ||
| crate::core::payment_methods::transformers::fetch_payment_method_from_modular_service( |
There was a problem hiding this comment.
nit: can we use absolute imports
| { | ||
| let mut authorize_attempt = payment_data.get_payment_attempt().clone(); | ||
| authorize_attempt.status = common_enums::AttemptStatus::Pending; | ||
| payment_data.set_payment_attempt(authorize_attempt); | ||
| } |
There was a problem hiding this comment.
can we take this decision based on authentication status
| ) | ||
| }); | ||
|
|
||
| if !is_frictionless_success { |
There was a problem hiding this comment.
pre_authentication should not be able to forse authentication whether the card is frictionless or not
| Some(payment_method_id) => { | ||
| let parent_payment_method_token = | ||
| common_utils::generate_id(consts::ID_LENGTH, "token"); | ||
| let token_data = storage::PaymentTokenData::PermanentCard( |
| let should_authorize_via_redirect = | ||
| !is_external_vault_payment && authorization_completes_in_this_call; | ||
| let response = if is_external_vault_payment && authorization_completes_in_this_call { | ||
| let payment_token = payment_attempt | ||
| .payment_token |
There was a problem hiding this comment.
can you simplify this logic, looks like we can reuse variables here
…idy external-vault 3DS payments.rs: - remove unreachable `is_frictionless_success` short-circuit and the now-unused `build_external_vault_pre_auth_authentication_store` (Netcetera /3ds/versioning always returns cavv=None, so pre-auth always halts for the SDK 3DS flow) - rename `persist_external_vault_pre_auth_challenge_and_prepare_sdk_invoke` -> `persist_external_vault_pre_auth_and_prepare_sdk_invoke` (runs for every external-vault 3DS, not only the challenge path) - guard the `AuthenticationPending -> Pending` transition before authorize - use the `pm_transformers` alias instead of a fully-qualified path payment_confirm_external_vault_proxy.rs: - preserve the attempt status on reject/confirm updates instead of hardcoding it - rename `halt_intent_status` -> `intent_status_optional` connector_config.rs: use `Self::Netcetera` Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… retrieve, pre-auth webhook_url - payment_confirm_external_vault_proxy.rs: source customer_acceptance from payment_data on the confirm update so the connector mandate is stored after external 3DS. - payment_methods.rs: return the vault alias for external-vault payment methods that have no locker_id instead of attempting a raw-card vault fetch, fixing the MIT/repeat-payment 500 (missing connector_vault_id). - payments.rs: thread the merchant webhook_url into the external-vault pre-authenticate router data. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Type of Change
Description
Adds External 3DS (Netcetera) authentication for cards held in an external VGS vault (v1 payments only). The card lives in the VGS vault; Hyperswitch holds only VGS aliases — never the PAN.
The 3DS authentication legs (versioning / AReq / RReq) run through the Unified Connector Service against the Netcetera authentication connector. The VGS alias is carried in the gRPC
card_proxyfield (never a Luhn-validated card field), and the real card fields are substituted by the VGS proxy on the outbound leg.Flow
confirm→ UCS pre-authenticate (card_proxyalias; netcetera MCA metadata forwarded asconnector_feature_data).ThreeDsInvokenext-action → SDK/3ds/authentication(AReq) → results (RRes) webhook vaults the CAVV → SDK re-confirm → authorize.pull=falseauthorizes via the RRes webhook; the SDK's post-challengethree_ds_authorize_urlonly reports status. Frictionless authorizes via the confirm/resume.CVC is modelled as optional on the vault-token path (the CAVV replaces it on a 3DS-authenticated authorize), consistent with the saved-card
CardTokenmodel.Notes
TODO(not yet supported).How did you test it?
Validated end-to-end on a local stack (VGS vault + Netcetera prev + Checkout): frictionless and challenge paths both charged end-to-end; save-card + repeat-customer verified. Re-verification of the CVC-optional change in progress.
test curls in the linked issue