From f7d8f72f29a456425e65705fecf4080adbc43d0a Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 03:57:47 +1000 Subject: [PATCH 01/10] =?UTF-8?q?=E2=80=A2=20Improve=20CyberSource=20provi?= =?UTF-8?q?der=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix cancel flow so cancelled payments restore the cart - Add configurable REST JWT authentication for certificate/shared-secret keys - Improve REST request signing, error handling, and debug logging - Clarify provider setting labels and help texts - Fix request formatting and callback validation edge cases --- src/CyberSource.cs | 209 ++++++++++++++--- ...mmerce.CheckoutHandlers.CyberSource.csproj | 4 +- src/Helpers/Helper.cs | 99 +++++++- src/Helpers/JwtAuthenticationHelper.cs | 169 ++++++++++++++ src/Helpers/SecurityHelper.cs | 4 +- src/RestAuthenticationMethod.cs | 7 + src/Service/CyberSourceRequest.cs | 221 +++++++++--------- src/Service/CyberSourceRequestLogger.cs | 197 ++++++++++++++++ .../CyberSourceRestAuthenticationSettings.cs | 60 +++++ src/Service/CyberSourceService.cs | 4 +- 10 files changed, 819 insertions(+), 155 deletions(-) create mode 100644 src/Helpers/JwtAuthenticationHelper.cs create mode 100644 src/RestAuthenticationMethod.cs create mode 100644 src/Service/CyberSourceRequestLogger.cs create mode 100644 src/Service/CyberSourceRestAuthenticationSettings.cs diff --git a/src/CyberSource.cs b/src/CyberSource.cs index 00bf1ec..952754b 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -24,11 +24,19 @@ namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource; /// [AddInName("CyberSource")] [AddInDescription("Payment system, http://www.cybersource.com")] -public class CyberSource : CheckoutHandlerWithStatusPage, IParameterOptions, IRemoteCapture, ISavedCard, IRecurring, ICheckAuthorizationStatus +[AddInUseParameterOrdering(true)] +public class CyberSource : CheckoutHandlerWithStatusPage, IParameterOptions, IParameterVisibility, IRemoteCapture, ISavedCard, IRecurring, ICheckAuthorizationStatus { private const string FormTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Payment"; private const string CancelTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Cancel"; private const string ErrorTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Error"; + private const string CancelReturnCommand = "CancelReturn"; + private const string CancelReturnTokenParameterName = "CancelReturnToken"; + private const string RestAuthenticationMethodParameterName = "REST authentication method"; + private const string CertificateParameterName = "Certificate"; + private const string CertificatePasswordParameterName = "Certificate password"; + private const string RestSharedSecretKeyIdParameterName = "REST Shared Secret Key ID"; + private const string RestSharedSecretParameterName = "REST Shared Secret"; private static readonly HashSet SupportedCountryCodes; private static readonly HashSet SupportedCurrencyCodes; @@ -84,24 +92,47 @@ public CyberSource() #region Addin parameters - [AddInParameter("Merchant id"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=This is the name of your sandbox account;")] + [AddInParameter("Merchant id"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=The CyberSource merchant account identifier used for REST API requests and payment processing. CyberSource Business Center shows this as Merchant ID in the account header or account selector. This is not the Account ID.;")] public string MerchantId { get; set; } - [AddInParameter("Profile id"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=This is a security key generated in the CyberSource Business Center under: Tools & Settings > Profiles > Security;")] + [AddInParameter("Profile id"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=The Secure Acceptance Hosted Checkout profile identifier used when redirecting customers to the CyberSource payment form. CyberSource Business Center: Payment Configuration > Secure Acceptance Settings > open or create a Hosted Checkout profile > Profile ID.;")] public string ProfileId { get; set; } - [AddInParameter("Access key"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=This is the public component of the security key;")] + [AddInParameter("Profile Access Key"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=The public key identifier for the Secure Acceptance Hosted Checkout profile. It is sent with hosted checkout form requests. CyberSource Business Center: Payment Configuration > Secure Acceptance Settings > open the Hosted Checkout profile > Security > Active Keys > View Key > Access Key.;")] public string AccessKey { get; set; } - [AddInParameter("Secret key"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;TextArea=true;infoText=This is the secret component of the security key;")] + [AddInParameter("Profile Secret Key"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;TextArea=true;infoText=The secret signing key for the Secure Acceptance Hosted Checkout profile. It signs outgoing hosted checkout form fields and validates CyberSource callback signatures. CyberSource Business Center: Payment Configuration > Secure Acceptance Settings > open the Hosted Checkout profile > Security > Active Keys > View Key > Secret Key.;")] public string SecretKey { get; set; } - [AddInParameter("Certificate"), AddInParameterEditor(typeof(FileManagerEditor), "NewGUI=true;allowBrowse=true;folder=System;showfullpath=true;infoText=The certificate for REST API, which should be uploaded to the Dynamicweb File Archive;")] + private RestAuthenticationMethod restAuthenticationMethod = RestAuthenticationMethod.Certificate; + + [AddInParameter(RestAuthenticationMethodParameterName)] + [AddInParameterEditor(typeof(RadioParameterEditor), "reloadOnChange=true;infoText=Choose how Dynamicweb signs CyberSource REST API requests for saved-card payments and captures. Secure Acceptance hosted checkout still uses the profile access and secret keys above.;")] + public string RestAuthenticationMethodName + { + get => restAuthenticationMethod.ToString(); + set + { + if (Enum.TryParse(value, out RestAuthenticationMethod parsedMethod)) + restAuthenticationMethod = parsedMethod; + } + } + + [AddInParameter(CertificateParameterName), AddInParameterEditor(typeof(FileManagerEditor), $"NewGUI=true;allowBrowse=true;folder={Helper.CertificateFolder};showfullpath=true;infoText=The REST API .p12 certificate used to sign certificate-based JWT requests for saved-card payments and captures. CyberSource Business Center: Payment Configuration > Key Management > Generate Key > REST APIs > REST - Certificate. Upload the downloaded .p12 file to /Files/System and select it here. This is separate from Secure Acceptance profile keys.;")] public string CertificateFile { get; set; } - [AddInParameter("Certificate password"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=The password to read certificate;")] + [AddInParameter(CertificatePasswordParameterName), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=The password that opens the REST API .p12 certificate file used for certificate-based JWT signing. It is entered when generating the REST - Certificate key in CyberSource Business Center: Payment Configuration > Key Management. This is not the Secure Acceptance Profile Secret Key.;")] public string CertificatePassword { get; set; } + [AddInParameter(RestSharedSecretKeyIdParameterName), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;infoText=The key identifier for REST Shared Secret JWT authentication, used as the JWT key id for saved-card payments and captures. CyberSource Business Center: Payment Configuration > Key Management > Generate Key > REST APIs > REST - Shared Secret > Key. Used only when REST authentication method is Shared Secret JWT.;")] + public string RestSharedSecretKeyId { get; set; } + + [AddInParameter(RestSharedSecretParameterName), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;TextArea=true;infoText=The base64-encoded secret value used to sign REST Shared Secret JWT requests for saved-card payments and captures. CyberSource Business Center: Payment Configuration > Key Management > Generate Key > REST APIs > REST - Shared Secret > Shared Secret. Used only when REST authentication method is Shared Secret JWT.;")] + public string RestSharedSecret { get; set; } + + [AddInParameter("Debug logging"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=Logs CyberSource API request and response details to order debugging and system logs. Payment-sensitive values are masked.;")] + public bool DebugLogging { get; set; } + private TransactionTypes transactionType = TransactionTypes.Sale; [AddInParameter("Transaction type")] @@ -112,7 +143,7 @@ public string TransactionType set => Enum.TryParse(value, out transactionType); } - [AddInParameter("Forced tokenization"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=;Forces the token to be saved on order or card for logged in users who have not chosen \"Save card\";")] + [AddInParameter("Forced tokenization"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=For logged-in users, save a payment token on the order or saved card even when the customer did not select \"Save card\".;")] public bool ForceTokenization { get; set; } private string paymentTemplate; @@ -162,17 +193,17 @@ public string WindowMode private string declineAVSFlag; - [AddInParameter("Review AVS Codes"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;Explanation=Cybersource supports AVS (Address Verification System) validation;Hint=Should contain the AVS codes you want to receive an AVS validation for;")] + [AddInParameter("Review AVS Codes"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;Explanation=CyberSource supports AVS (Address Verification System) validation;Hint=Space-separated AVS result codes that CyberSource should decline. Default is N: street address and postal code do not match.;")] public string Decline_AVS_Flag { get => string.IsNullOrEmpty(declineAVSFlag) ? "N" : declineAVSFlag; set => declineAVSFlag = value; } - [AddInParameter("Ignore AVS Result"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=When Ignore AVS results is checked, you will receive no AVS declines;")] + [AddInParameter("Ignore AVS Result"), AddInParameterEditor(typeof(YesNoParameterEditor), "infoText=When checked, CyberSource ignores AVS declines and allows capture to run even when authorization receives an AVS decline.;")] public bool Ignore_AVS_Result { get; set; } = false; - [AddInParameter("Approve AVS Code"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true; Explanation=Cybersource supports AVS (Address Verification System) validation;Hint=Should contain a comma-separated list of AVS codes which will permit the transaction to be approved;")] + [AddInParameter("Approve AVS Code"), AddInParameterEditor(typeof(TextParameterEditor), "NewGUI=true;Explanation=CyberSource supports AVS (Address Verification System) validation;Hint=Optional comma- or space-separated AVS result codes that Dynamicweb accepts after CyberSource returns the payment. If configured and the returned code is not listed, the order is rejected.;")] public string Result_AVS_Flag { get; set; } #endregion @@ -184,6 +215,54 @@ private string GetHost() return $"{apiType}.cybersource.com"; } + private bool ValidateRestApiSettings(out string errorMessage) + { + errorMessage = string.Empty; + + if (string.IsNullOrWhiteSpace(MerchantId)) + { + errorMessage = "Merchant id must be configured"; + return false; + } + + if (restAuthenticationMethod is RestAuthenticationMethod.Certificate) + { + string certPath = Helper.GetCertificateFilePath(CertificateFile); + if (string.IsNullOrWhiteSpace(certPath)) + { + errorMessage = "Certificate for REST API is not found"; + return false; + } + + return true; + } + + if (string.IsNullOrWhiteSpace(RestSharedSecretKeyId)) + { + errorMessage = "REST Shared Secret Key ID must be configured"; + return false; + } + + if (string.IsNullOrWhiteSpace(RestSharedSecret)) + { + errorMessage = "REST Shared Secret must be configured"; + return false; + } + + return true; + } + + private CyberSourceRestAuthenticationSettings GetRestAuthenticationSettings() => new() + { + MerchantId = MerchantId, + AuthenticationMethod = restAuthenticationMethod, + CertificateFile = CertificateFile, + CertificatePassword = CertificatePassword, + SharedSecretKeyId = RestSharedSecretKeyId, + SharedSecret = RestSharedSecret, + DebugLogging = DebugLogging + }; + /// /// Gets options according to behavior mode /// @@ -209,23 +288,36 @@ public IEnumerable GetParameterOptions(string parameterName) new("Embedded", WindowModes.Embedded.ToString()) }; + case RestAuthenticationMethodParameterName: + return new List + { + new("Certificate JWT", RestAuthenticationMethod.Certificate.ToString()) + { + Hint = "Use a 'REST - Certificate' .p12 file from Payment Configuration > Key Management." + }, + new("Shared Secret JWT", RestAuthenticationMethod.SharedSecret.ToString()) + { + Hint = "Use 'REST - Shared Secret' credentials from Payment Configuration > Key Management." + } + }; + case "Transaction type": return new List { new("Authorization (zero amount)", TransactionTypes.ZeroAuthorization.ToString()) { - Hint = "All transactions are zero authorized. " + - "Capture is performed through AX or similar and you can carry out account " + - "verification checks to check the validity of a Visa/MasterCard Debit or credit card" + Hint = "Sends a zero-amount authorization/account verification request to CyberSource. " + + "Use it to validate the card or create a payment token without authorizing or capturing the order amount." }, new("Authorization (order amount)", TransactionTypes.Authorization.ToString()) { - Hint = " The order is authorized at AuthorizeNET and then you can " + - "manually authorize from ecommerce backend order list. This is used for usual transactions" + Hint = "Authorizes the order amount in CyberSource without capturing it. " + + "Capture the authorized payment later from the Dynamicweb order list or another back-office flow." }, - new("Sale",TransactionTypes.Sale.ToString()) + new("Sale", TransactionTypes.Sale.ToString()) { - Hint = "The amount is sent for authorization, and if approved, is automatically submitted for settlement" + Hint = "Authorizes and captures the order amount in one CyberSource transaction. " + + "If approved, the payment is submitted for settlement." } }; @@ -244,6 +336,20 @@ public IEnumerable GetParameterOptions(string parameterName) } } + public IEnumerable GetHiddenParameterNames(string parameterName, object parameterValue) + { + if (!string.Equals(parameterName, RestAuthenticationMethodParameterName, StringComparison.OrdinalIgnoreCase)) + return Enumerable.Empty(); + + RestAuthenticationMethod selectedMethod = restAuthenticationMethod; + if (parameterValue is not null && Enum.TryParse(parameterValue.ToString(), out RestAuthenticationMethod parsedMethod)) + selectedMethod = parsedMethod; + + return selectedMethod is RestAuthenticationMethod.SharedSecret + ? new[] { CertificateParameterName, CertificatePasswordParameterName } + : new[] { RestSharedSecretKeyIdParameterName, RestSharedSecretParameterName }; + } + /// /// Send capture request to transaction service /// @@ -259,9 +365,8 @@ OrderCaptureInfo IRemoteCapture.Capture(Order order) else if (string.IsNullOrWhiteSpace(order.TransactionNumber)) errorMessage = "No transaction number set on the order"; - string certPath = Helper.GetCertificateFilePath(CertificateFile); - if (string.IsNullOrWhiteSpace(certPath)) - errorMessage = "Certificate for REST API is not found"; + if (string.IsNullOrEmpty(errorMessage) && !ValidateRestApiSettings(out string restApiErrorMessage)) + errorMessage = restApiErrorMessage; if (!string.IsNullOrEmpty(errorMessage)) { @@ -269,7 +374,7 @@ OrderCaptureInfo IRemoteCapture.Capture(Order order) return new OrderCaptureInfo(OrderCaptureInfo.OrderCaptureState.Failed, errorMessage); } - var service = new CyberSourceService(GetHost(), MerchantId, CertificateFile, CertificatePassword); + var service = new CyberSourceService(GetHost(), GetRestAuthenticationSettings()); CaptureResponse response = service.Capture(order, order.TransactionNumber); LogEvent(order, $"Capture successful. Id: {response.Id}, Status: {response.Status}", DebuggingInfoType.CaptureResult); @@ -386,6 +491,8 @@ public override OutputResult HandleRequest(Order order) return StateCardSaved(order); case "Cancel": return StateCancel(order); + case CancelReturnCommand: + return StateCancelReturn(order); case "IFrameError": return StateIFrameError(order); default: @@ -458,6 +565,25 @@ private OutputResult StateCancel(Order order) return OnError(order, "Wrong signature"); } + // CyberSource sends cancel as a cross-site POST, where storefront auth cookies can be withheld by dynamicweb site. + // Redirect to a same-site GET before CheckoutDone so the regular cart restoration can resolve the user context. + return new RedirectOutputResult + { + RedirectUrl = GetCancelReturnUrl(order) + }; + } + + private OutputResult StateCancelReturn(Order order) + { + LogEvent(order, "State cancel return"); + + if (!ValidateCancelReturnToken(order)) + { + LogError(order, "The cancel return token is invalid."); + return NotFoundOutputResult.Default; + } + + order.TransactionAmount = 0; order.TransactionStatus = "Cancelled"; Services.Orders.Save(order); CheckoutDone(order); @@ -831,6 +957,34 @@ private string GetAcceptUrl(Order order) private string GetCancelUrl(Order order) => $"{GetBaseUrl(order)}&cmd=Cancel"; + private string GetCancelReturnUrl(Order order) + { + string cancelReturnToken = WebUtility.UrlEncode(GetCancelReturnToken(order)); + + return $"{GetBaseUrl(order)}&cmd={CancelReturnCommand}&{CancelReturnTokenParameterName}={cancelReturnToken}"; + } + + private bool ValidateCancelReturnToken(Order order) + { + string cancelReturnToken = Context.Current.Request[CancelReturnTokenParameterName]; + + return !string.IsNullOrEmpty(cancelReturnToken) + && string.Equals(cancelReturnToken, GetCancelReturnToken(order), StringComparison.Ordinal); + } + + private string GetCancelReturnToken(Order order) + { + var parameters = new Dictionary + { + ["order_id"] = order.Id ?? string.Empty, + ["order_secret"] = order.Secret ?? string.Empty, + ["cmd"] = CancelReturnCommand, + ["signed_field_names"] = "order_id,order_secret,cmd" + }; + + return SecurityHelper.Sign(parameters, SecretKey); + } + private string GetLanguageCode() => Environment.ExecutingContext.GetCulture(true).TwoLetterISOLanguageName; #region Gateway URLs @@ -919,9 +1073,7 @@ string ProcessError(string errorMessage) public bool SavedCardSupported(Order order) { - string certPath = Helper.GetCertificateFilePath(CertificateFile); - - return !string.IsNullOrWhiteSpace(certPath); + return ValidateRestApiSettings(out _); } private OutputResult UseSavedCardInternal(Order order) @@ -930,9 +1082,8 @@ private OutputResult UseSavedCardInternal(Order order) if (savedCard is null || order.CustomerAccessUserId != savedCard.UserID) throw new Exception("Token is incorrect."); - string certPath = Helper.GetCertificateFilePath(CertificateFile); - if (string.IsNullOrWhiteSpace(certPath)) - throw new Exception("Certificate for REST API is not found"); + if (!ValidateRestApiSettings(out string errorMessage)) + throw new Exception(errorMessage); LogEvent(order, "Using saved card({0}) with id: {1}", savedCard.Identifier, savedCard.ID); @@ -948,7 +1099,7 @@ private OutputResult UseSavedCardInternal(Order order) return PassToCart(order); } - var service = new CyberSourceService(GetHost(), MerchantId, CertificateFile, CertificatePassword); + var service = new CyberSourceService(GetHost(), GetRestAuthenticationSettings()); PaymentResponse response = service.CreatePayment(order, savedCard); string transactionId = response.Id; diff --git a/src/Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.csproj b/src/Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.csproj index 1d5da1e..6d0781d 100644 --- a/src/Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.csproj +++ b/src/Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.csproj @@ -1,6 +1,6 @@  - 10.11.2 + 10.25.0 1.0.0.0 CyberSource "Payment system, http://www.cybersource.com" @@ -34,7 +34,7 @@ - + diff --git a/src/Helpers/Helper.cs b/src/Helpers/Helper.cs index 3663264..eb244f6 100644 --- a/src/Helpers/Helper.cs +++ b/src/Helpers/Helper.cs @@ -1,19 +1,23 @@ using Dynamicweb.Core.Helpers; using Dynamicweb.Ecommerce.Orders; +using Dynamicweb.Security.UserManagement; using System; using System.Globalization; using System.IO; +using System.Linq; namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Helpers; internal static class Helper { + internal const string CertificateFolder = "/Files/System"; + public static string GetCertificateFilePath(string certificateFile) { if (string.IsNullOrWhiteSpace(certificateFile)) return string.Empty; - string path = FilePathHelper.GetAbsolutePath(certificateFile); + string path = FilePathHelper.GetAbsolutePath(certificateFile, CertificateFolder); if (File.Exists(path)) return path; @@ -23,9 +27,22 @@ public static string GetCertificateFilePath(string certificateFile) public static string GetCustomerLastName(Order order, string customerName) { string lastName = order.CustomerSurname; - int delimeterPosition = customerName.IndexOf(' '); + int delimiterPosition = customerName.IndexOf(' '); + if (string.IsNullOrWhiteSpace(lastName)) + { + lastName = delimiterPosition > -1 + ? customerName.Substring(delimiterPosition + 1) + : customerName; + } + if (string.IsNullOrWhiteSpace(lastName)) - lastName = delimeterPosition > -1 ? customerName.Substring(delimeterPosition + 1) : customerName; + { + User user = GetOrderUser(order); + lastName = GetFirstValue(user?.LastName, + GetLastName(user?.Name), + GetLastName(user?.UserName), + GetFallbackCustomerName(order, user)); + } return lastName; } @@ -33,9 +50,22 @@ public static string GetCustomerLastName(Order order, string customerName) public static string GetCustomerFirstName(Order order, string customerName) { string firstName = order.CustomerFirstName; - int delimeterPosition = customerName.IndexOf(' '); + int delimiterPosition = customerName.IndexOf(' '); if (string.IsNullOrWhiteSpace(firstName)) - firstName = delimeterPosition > -1 ? customerName.Substring(0, delimeterPosition) : customerName; + { + firstName = delimiterPosition > -1 + ? customerName.Substring(0, delimiterPosition) + : customerName; + } + + if (string.IsNullOrWhiteSpace(firstName)) + { + User user = GetOrderUser(order); + firstName = GetFirstValue(user?.FirstName, + GetFirstName(user?.Name), + GetFirstName(user?.UserName), + GetFallbackCustomerName(order, user)); + } return firstName; } @@ -50,8 +80,65 @@ public static string GetTransactionAmount(Order order) ? "0." + new string('0', decimals) : "0"; - string amount = Math.Round(order.Price.Price, decimals).ToString(amountFormat, CultureInfo.InvariantCulture); + string amount = Math.Round(order.Price.Price, decimals) + .ToString(amountFormat, CultureInfo.InvariantCulture); return amount; } + + private static string GetFirstValue(params string[] values) + { + return values + .Select(value => value?.Trim()) + .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty; + } + + private static User GetOrderUser(Order order) + { + if (order.CustomerAccessUserId <= 0) + return null; + + return UserManagementServices.Users.GetUserById(order.CustomerAccessUserId); + } + + private static string GetFallbackCustomerName(Order order, User user) + { + return GetFirstValue( + order.CustomerCompany, + user?.Name, + user?.UserName, + GetEmailUserName(order.CustomerEmail), + order.Id, + "Customer"); + } + + private static string GetFirstName(string name) + { + name = name?.Trim() ?? string.Empty; + int delimiterPosition = name.IndexOf(' '); + + return delimiterPosition > -1 + ? name.Substring(0, delimiterPosition) + : name; + } + + private static string GetLastName(string name) + { + name = name?.Trim() ?? string.Empty; + int delimiterPosition = name.IndexOf(' '); + + return delimiterPosition > -1 + ? name.Substring(delimiterPosition + 1) + : name; + } + + private static string GetEmailUserName(string email) + { + email = email?.Trim() ?? string.Empty; + int delimiterPosition = email.IndexOf('@'); + + return delimiterPosition > -1 + ? email.Substring(0, delimiterPosition) + : email; + } } diff --git a/src/Helpers/JwtAuthenticationHelper.cs b/src/Helpers/JwtAuthenticationHelper.cs new file mode 100644 index 0000000..3d0a32a --- /dev/null +++ b/src/Helpers/JwtAuthenticationHelper.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Helpers; + +internal static class JwtAuthenticationHelper +{ + /// + /// Generates a CyberSource REST API JWT v2 authentication token. + /// The required claims follow the current CyberSource REST JWT documentation and match the official + /// CyberSource .NET Standard authentication SDK output. + /// See https://developer.cybersource.com/docs/cybs/en-us/platform/developer/all/rest/rest-getting-started/restgs-jwt-message-intro/restgs-security-p12-intro.html. + /// + public static string GenerateCertificateToken(string merchantId, string certificateFile, string certificatePassword, HttpMethod method, string host, string resourcePath, string data) + { + try + { + string certificatePath = Helper.GetCertificateFilePath(certificateFile); + if (string.IsNullOrEmpty(certificatePath)) + throw new Exception("Certificate for REST API is not found"); + + using X509Certificate2 x5Cert = new(certificatePath, certificatePassword, X509KeyStorageFlags.MachineKeySet); + using RSA privateKey = x5Cert.GetRSAPrivateKey(); + if (privateKey is null) + throw new Exception("Certificate for REST API does not contain an RSA private key"); + + Dictionary header = GetHeaderClaims("RS256", GetCertificateKeyId(x5Cert)); + Dictionary payload = GetPayloadClaims(merchantId, method, host, resourcePath, data); + + return BuildJwt( + JsonSerializer.Serialize(header), + JsonSerializer.Serialize(payload), + privateKey); + } + catch (Exception ex) + { + throw new Exception("Certificate JWT token create failed", ex); + } + } + + /// + /// Generates a CyberSource REST API JWT v2 token signed with a REST Shared Secret key. + /// + /// The CyberSource merchant ID used as the JWT issuer and merchant identifier. + /// The REST Shared Secret key ID from CyberSource Business Center Key Management. + /// The base64-encoded REST Shared Secret value from CyberSource Business Center Key Management. + /// The HTTP method used for the CyberSource REST request. + /// The CyberSource REST API host, for example apitest.cybersource.com. + /// The REST resource path and query string being requested. + /// The serialized request body. When present, its SHA-256 digest is included in the JWT payload. + /// A signed JWT token suitable for the CyberSource REST API Authorization: Bearer header. + /// + /// Thrown when required shared-secret settings are missing, the shared secret is not valid base64, or token signing fails. + /// + public static string GenerateSharedSecretToken(string merchantId, string sharedSecretKeyId, string sharedSecret, HttpMethod method, string host, string resourcePath, string data) + { + try + { + if (string.IsNullOrWhiteSpace(sharedSecretKeyId)) + throw new Exception("REST Shared Secret Key ID is not configured"); + if (string.IsNullOrWhiteSpace(sharedSecret)) + throw new Exception("REST Shared Secret is not configured"); + + Dictionary header = GetHeaderClaims("HS256", sharedSecretKeyId); + Dictionary payload = GetPayloadClaims(merchantId, method, host, resourcePath, data); + + return BuildJwt( + JsonSerializer.Serialize(header), + JsonSerializer.Serialize(payload), + Convert.FromBase64String(sharedSecret.Trim())); + } + catch (Exception ex) + { + throw new Exception("Shared Secret JWT token create failed", ex); + } + } + + private static Dictionary GetHeaderClaims(string algorithm, string keyId) => new() + { + ["alg"] = algorithm, + ["kid"] = keyId, + ["typ"] = "JWT" + }; + + private static Dictionary GetPayloadClaims(string merchantId, HttpMethod method, string host, string resourcePath, string data) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var payload = new Dictionary + { + ["iat"] = now.ToUnixTimeSeconds(), + ["exp"] = now.AddMinutes(2).ToUnixTimeSeconds(), + ["request-method"] = method.Method.ToLowerInvariant(), + ["request-resource-path"] = resourcePath, + ["request-host"] = host, + ["iss"] = merchantId, + ["jti"] = Guid.NewGuid().ToString(), + ["v-c-jwt-version"] = "2", + ["v-c-merchant-id"] = merchantId + }; + + if (!string.IsNullOrEmpty(data)) + { + payload["digest"] = GenerateDigest(data); + payload["digestAlgorithm"] = "SHA-256"; + } + + return payload; + } + + private static string BuildJwt(string header, string payload, RSA privateKey) + { + string signingInput = $"{Base64UrlEncode(header)}.{Base64UrlEncode(payload)}"; + byte[] signature = privateKey.SignData(Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + return $"{signingInput}.{Base64UrlEncode(signature)}"; + } + + private static string BuildJwt(string header, string payload, byte[] sharedSecret) + { + string signingInput = $"{Base64UrlEncode(header)}.{Base64UrlEncode(payload)}"; + using HMACSHA256 hmac = new(sharedSecret); + byte[] signature = hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput)); + + return $"{signingInput}.{Base64UrlEncode(signature)}"; + } + + private static string Base64UrlEncode(string value) => Base64UrlEncode(Encoding.UTF8.GetBytes(value)); + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static string GenerateDigest(string data) + { + using SHA256 sha256 = SHA256.Create(); + + return Convert.ToBase64String(sha256.ComputeHash(Encoding.UTF8.GetBytes(data))); + } + + private static string GetCertificateKeyId(X509Certificate2 certificate) + { + Match subjectSerialNumber = Regex.Match(certificate.Subject, @"(?:^|,\s*)SERIALNUMBER\s*=\s*([^,]+)", RegexOptions.IgnoreCase); + if (subjectSerialNumber.Success) + return subjectSerialNumber.Groups[1].Value.Trim(); + + string decodedSerialNumber = DecodeHexSerialNumber(certificate.SerialNumber); + + return string.IsNullOrWhiteSpace(decodedSerialNumber) ? certificate.SerialNumber : decodedSerialNumber; + } + + private static string DecodeHexSerialNumber(string serialNumber) + { + if (string.IsNullOrEmpty(serialNumber) || serialNumber.Length % 2 is not 0 || serialNumber.Any(character => !Uri.IsHexDigit(character))) + return string.Empty; + + byte[] bytes = Enumerable.Range(0, serialNumber.Length / 2) + .Select(index => Convert.ToByte(serialNumber.Substring(index * 2, 2), 16)) + .ToArray(); + + string decoded = Encoding.ASCII.GetString(bytes); + return decoded.All(character => char.IsLetterOrDigit(character)) ? decoded : string.Empty; + } +} diff --git a/src/Helpers/SecurityHelper.cs b/src/Helpers/SecurityHelper.cs index 77dcf99..27d5e71 100644 --- a/src/Helpers/SecurityHelper.cs +++ b/src/Helpers/SecurityHelper.cs @@ -57,8 +57,8 @@ public static bool ValidateResponseSignation(NameValueCollection parameters, str /// Signs parameters with secret key /// /// set of key value pairs - /// key that is used for encryption - /// Encrypted string + /// key that is used for signing + /// Signature string public static string Sign(Dictionary parameters, string secretKey) { return Sign(BuildSignation(parameters), secretKey); diff --git a/src/RestAuthenticationMethod.cs b/src/RestAuthenticationMethod.cs new file mode 100644 index 0000000..8aa5de6 --- /dev/null +++ b/src/RestAuthenticationMethod.cs @@ -0,0 +1,7 @@ +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource; + +internal enum RestAuthenticationMethod +{ + Certificate, + SharedSecret +} diff --git a/src/Service/CyberSourceRequest.cs b/src/Service/CyberSourceRequest.cs index f25e314..3136e2a 100644 --- a/src/Service/CyberSourceRequest.cs +++ b/src/Service/CyberSourceRequest.cs @@ -3,15 +3,10 @@ using Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Models.Request.Error; using Dynamicweb.Ecommerce.Orders; using System; -using System.Collections.Generic; using System.Linq; -using System.Text.Json; using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.IO; -using System.Security.Cryptography.X509Certificates; using System.Text; namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Service; @@ -21,27 +16,18 @@ namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Service; /// internal sealed class CyberSourceRequest { - private static readonly HttpClient httpClient = new HttpClient( - new HttpClientHandler { AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip }) - { - Timeout = TimeSpan.FromSeconds(90) - }; - - public string MerchantId { get; set; } - - public string CertificateFile { get; set; } + private static readonly HttpClient Client = CreateHttpClient(); - public string CertificatePassword { get; set; } + public CyberSourceRestAuthenticationSettings AuthenticationSettings { get; set; } - public CyberSourceRequest(string merchantId, string certificateFile, string certificatePassword) + public CyberSourceRequest(CyberSourceRestAuthenticationSettings authenticationSettings) { - MerchantId = merchantId; - CertificateFile = certificateFile; - CertificatePassword = certificatePassword; + AuthenticationSettings = authenticationSettings; } public string SendRequest(Order order, string host, CommandConfiguration configuration) { + UriBuilder baseAddress = GetBaseAddress(host); HttpMethod method = configuration.CommandType switch { ApiCommand.CreatePayment or @@ -50,135 +36,142 @@ ApiCommand.CreatePayment or }; string data = Converter.Serialize(configuration.Data); - string jwtToken = GenerateJWT(order, method, data); - - UriBuilder baseAddress = GetBaseAddress(host); string apiCommand = GetCommandLink(baseAddress, configuration.CommandType, configuration.OperatorId); + var requestLogger = new CyberSourceRequestLogger(AuthenticationSettings.DebugLogging, order); + requestLogger.LogRequest(method, apiCommand, data); + + using var request = new HttpRequestMessage(method, apiCommand); + request.Content = method switch + { + _ when method == HttpMethod.Post => GetContent(data), + _ => throw new NotSupportedException($"Unknown http method was used: {method.ToString()}.") + }; - using var requestMessage = new HttpRequestMessage(method, apiCommand); - requestMessage.Headers.Host = host; - requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken); - if (method == HttpMethod.Post) - requestMessage.Content = new StringContent(data, Encoding.UTF8, "application/json"); + AddAuthenticationHeaders(request, host, data); try { - using HttpResponseMessage response = httpClient.Send(requestMessage); + using HttpResponseMessage response = Client + .SendAsync(request) + .GetAwaiter() + .GetResult(); + + string responseText = response.Content + .ReadAsStringAsync() + .GetAwaiter() + .GetResult(); - Log(order, $"Remote server response: HttpStatusCode = {response.StatusCode}, HttpStatusDescription = {response.ReasonPhrase}"); - using var stream = response.Content.ReadAsStream(); - using var reader = new StreamReader(stream, Encoding.UTF8); - string responseText = reader.ReadToEnd(); - Log(order, $"Remote server ResponseText: {responseText}"); + requestLogger.LogResponse(response, responseText); if (!response.IsSuccessStatusCode) { - var errorResponse = Converter.Deserialize(responseText); - if (string.IsNullOrEmpty(errorResponse.Status)) - throw new Exception($"Unhandled exception. Operation failed: '{response.ReasonPhrase}'. Response text: '{responseText}'"); - - string errorMessage = $"Operation failed. Status: '{errorResponse.Status}'. Reason: '{errorResponse.Reason}'. Message: '{errorResponse.Message}'."; - if (response.StatusCode is HttpStatusCode.BadRequest) - { - if (errorResponse.Details?.Any() is true) - { - var detailsMessage = new StringBuilder(); - foreach (ErrorDetail detail in errorResponse.Details) - detailsMessage.AppendLine($"{detail.Field}: {detail.Reason}"); - - errorMessage += $" Details: '{detailsMessage}'"; - } - } - throw new Exception(errorMessage); + const string userReadableErrorMessage = "CyberSource payment request failed. See the order debug log for details."; + LogErrorResponse(requestLogger, response, responseText); + + throw new Exception(userReadableErrorMessage); } return responseText; } catch (HttpRequestException requestException) { - throw new Exception($"An error occurred during CyberSource request: {requestException.Message}", requestException); - } - } - - private UriBuilder GetBaseAddress(string host) => new UriBuilder(Uri.UriSchemeHttps, host); + requestLogger.LogException(requestException); - private string GetCommandLink(UriBuilder baseAddress, ApiCommand command, string operatorId) - { - return command switch - { - ApiCommand.CreatePayment => GetCommandLink("payments"), - ApiCommand.CapturePayment => GetCommandLink($"payments/{operatorId}/captures"), - _ => throw new NotSupportedException($"The api command is not supported. Command: {command}") - }; - - string GetCommandLink(string gateway) + throw new Exception("An error occurred during CyberSource request. See the order debug log for details.", requestException); + } + finally { - baseAddress.Path = $"pts/v2/{gateway}"; - return baseAddress.ToString(); + try + { + requestLogger.Flush(); + } + catch + { + // Diagnostic logging must not change the payment request result. + } } } - private void Log(Order order, string message) - { - if (order is null) - return; - - Services.OrderDebuggingInfos.Save(order, message, typeof(CyberSource).FullName, DebuggingInfoType.Undefined); - } - - /// - /// This method demonstrates the creation of the JWT Authentication credential - /// Takes Request Payload and Http method(GET/POST) as input. - /// This code is an example from: https://github.com/CyberSource/cybersource-rest-samples-csharp/blob/master/Source/Samples/Authentication/StandAloneJWT.cs - /// - /// Value from which to generate JWT - /// The HTTP Verb that is needed for generating the credential - /// String containing the JWT Authentication credential - private string GenerateJWT(Order order, HttpMethod method, string data) + private static void LogErrorResponse(CyberSourceRequestLogger requestLogger, HttpResponseMessage response, string responseText) { try { - using SHA256 sha256 = SHA256.Create(); - string digest = Convert.ToBase64String(sha256.ComputeHash(Encoding.UTF8.GetBytes(data))); - - string iat = DateTime.UtcNow.ToString("r"); - string jwtBody = method == HttpMethod.Post - ? $"{{\"digest\":\"{digest}\", \"digestAlgorithm\":\"SHA-256\", \"iat\":\"{iat}\"}}" - : $"{{\"iat\":\"{iat}\"}}"; - - string certificatePath = Helper.GetCertificateFilePath(CertificateFile); - if (string.IsNullOrEmpty(certificatePath)) - throw new Exception("Certificate for REST API is not found"); + var errorResponse = Converter.Deserialize(responseText); - using X509Certificate2 x5Cert = new X509Certificate2(certificatePath, CertificatePassword, X509KeyStorageFlags.MachineKeySet); - string x5cPublicKey = Convert.ToBase64String(x5Cert.RawData); - using RSA privateKey = x5Cert.GetRSAPrivateKey(); + if (string.IsNullOrEmpty(errorResponse.Status)) + { + requestLogger.LogParsedResponse($"Unhandled CyberSource response. Reason phrase: '{response.ReasonPhrase}'."); + return; + } - var header = new Dictionary + string errorMessage = $"Operation failed. Status: '{errorResponse.Status}'. Reason: '{errorResponse.Reason}'. Message: '{errorResponse.Message}'."; + if (response.StatusCode is HttpStatusCode.BadRequest && errorResponse.Details?.Any() is true) { - ["alg"] = "RS256", - ["typ"] = "JWT", - ["v-c-merchant-id"] = MerchantId, - ["x5c"] = new[] { x5cPublicKey } - }; + var detailsMessage = new StringBuilder(); + foreach (ErrorDetail detail in errorResponse.Details) + detailsMessage.AppendLine($"{detail.Field}: {detail.Reason}"); + + errorMessage += $" Details: '{detailsMessage.ToString()}'"; + } - return BuildJwt(JsonSerializer.Serialize(header), jwtBody, privateKey); + requestLogger.LogParsedResponse(errorMessage); } catch (Exception ex) { - throw new Exception("JWT token create failed", ex); + // The raw response is already logged; parsed error details are diagnostic-only. + requestLogger.LogException(ex); } } - private static string BuildJwt(string header, string payload, RSA privateKey) + private static HttpContent GetContent(string content) => new StringContent(content, Encoding.UTF8, "application/json"); + + private void AddAuthenticationHeaders(HttpRequestMessage request, string host, string data) { - string signingInput = $"{Base64UrlEncode(header)}.{Base64UrlEncode(payload)}"; - byte[] signature = privateKey.SignData(Encoding.UTF8.GetBytes(signingInput), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); - return $"{signingInput}.{Base64UrlEncode(signature)}"; + string resourcePath = request.RequestUri?.PathAndQuery ?? "/"; + string jwtToken = AuthenticationSettings.AuthenticationMethod switch + { + RestAuthenticationMethod.Certificate => JwtAuthenticationHelper.GenerateCertificateToken( + AuthenticationSettings.MerchantId, + AuthenticationSettings.CertificateFile, + AuthenticationSettings.CertificatePassword, + request.Method, host, resourcePath, data), + RestAuthenticationMethod.SharedSecret => JwtAuthenticationHelper.GenerateSharedSecretToken( + AuthenticationSettings.MerchantId, + AuthenticationSettings.SharedSecretKeyId, + AuthenticationSettings.SharedSecret, + request.Method, host, resourcePath, data), + _ => throw new NotSupportedException($"Unsupported REST authentication method: {AuthenticationSettings.AuthenticationMethod}") + }; + + request.Headers.Host = host; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwtToken); } - private static string Base64UrlEncode(string value) => Base64UrlEncode(Encoding.UTF8.GetBytes(value)); + private static HttpClient CreateHttpClient() => new(new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip, + PooledConnectionLifetime = TimeSpan.FromMinutes(15) + }) + { + Timeout = TimeSpan.FromSeconds(90) + }; + + private UriBuilder GetBaseAddress(string host) => new UriBuilder(Uri.UriSchemeHttps, host); + + private string GetCommandLink(UriBuilder baseAddress, ApiCommand command, string operatorId) + { + return command switch + { + ApiCommand.CreatePayment => GetCommandLink("payments"), + ApiCommand.CapturePayment => GetCommandLink($"payments/{operatorId}/captures"), + _ => throw new NotSupportedException($"The api command is not supported. Command: {command}") + }; + + string GetCommandLink(string gateway) + { + baseAddress.Path = $"pts/v2/{gateway}"; + return baseAddress.ToString(); + } + } - private static string Base64UrlEncode(byte[] bytes) => - Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); -} +} \ No newline at end of file diff --git a/src/Service/CyberSourceRequestLogger.cs b/src/Service/CyberSourceRequestLogger.cs new file mode 100644 index 0000000..5b4cff7 --- /dev/null +++ b/src/Service/CyberSourceRequestLogger.cs @@ -0,0 +1,197 @@ +using Dynamicweb.Ecommerce.Orders; +using Dynamicweb.Logging; +using System; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Service; + +internal sealed class CyberSourceRequestLogger +{ + private const string HiddenValue = "[HIDDEN]"; + private static readonly JsonSerializerOptions LogJsonSerializerOptions = new() { WriteIndented = true }; + + private readonly bool _enabled; + private readonly Order _order; + private readonly StringBuilder _logBuilder = new(); + + public CyberSourceRequestLogger(bool enabled, Order order) + { + _enabled = enabled; + _order = order; + } + + public void LogRequest(HttpMethod method, string requestUri, string requestBody) + { + if (!_enabled) + return; + + _logBuilder.AppendLine("CyberSource API Interaction Log:"); + _logBuilder.AppendLine(); + _logBuilder.AppendLine("--- REQUEST ---"); + _logBuilder.Append(CultureInfo.InvariantCulture, $"Method: {method.Method}").AppendLine(); + _logBuilder.Append(CultureInfo.InvariantCulture, $"URL: {requestUri}").AppendLine(); + _logBuilder.AppendLine("Authentication headers: [HIDDEN]"); + + _logBuilder.AppendLine(); + _logBuilder.AppendLine("--- REQUEST BODY ---"); + _logBuilder.AppendLine(SanitizeJsonForLog(requestBody)); + } + + public void LogResponse(HttpResponseMessage response, string responseBody) + { + if (!_enabled) + return; + + _logBuilder.AppendLine(); + _logBuilder.AppendLine("--- RESPONSE ---"); + _logBuilder.Append(CultureInfo.InvariantCulture, $"HttpStatusCode: {(int)response.StatusCode} ({response.ReasonPhrase})").AppendLine(); + + _logBuilder.AppendLine(); + _logBuilder.AppendLine("--- RESPONSE BODY ---"); + _logBuilder.AppendLine(SanitizeJsonForLog(responseBody)); + } + + public void LogParsedResponse(string message) + { + if (!_enabled) + return; + + _logBuilder.AppendLine(); + _logBuilder.AppendLine("--- PARSED RESPONSE ---"); + _logBuilder.AppendLine(SanitizeJsonForLog(message)); + } + + public void LogException(Exception exception) + { + if (!_enabled) + return; + + _logBuilder.AppendLine(); + _logBuilder.Append(CultureInfo.InvariantCulture, $"--- EXCEPTION ({exception.GetType().Name}) ---").AppendLine(); + _logBuilder.AppendLine(SanitizeJsonForLog(exception.Message)); + } + + public void Flush() + { + if (!_enabled || _logBuilder.Length == 0) + return; + + _logBuilder.AppendLine(); + _logBuilder.AppendLine("--- END OF INTERACTION ---"); + + string message = _logBuilder.ToString(); + ILogger logger = LogManager.System.GetLogger(LogCategory.Provider, typeof(CyberSource).FullName ?? nameof(CyberSource)); + logger.Info(message); + + if (_order is not null) + Services.OrderDebuggingInfos.Save(_order, message, typeof(CyberSource).FullName, DebuggingInfoType.Undefined); + } + + internal static string SanitizeJsonForLog(string json) + { + if (string.IsNullOrWhiteSpace(json)) + return "(empty body)"; + + try + { + JsonNode node = JsonNode.Parse(json); + if (node is null) + return json; + + SanitizeNode(node, string.Empty); + return node.ToJsonString(LogJsonSerializerOptions); + } + catch (JsonException) + { + return json; + } + } + + private static void SanitizeNode(JsonNode node, string path) + { + if (node is JsonObject jsonObject) + { + foreach (var property in jsonObject.ToList()) + { + string propertyPath = string.IsNullOrEmpty(path) ? property.Key : $"{path}.{property.Key}"; + if (property.Value is JsonValue jsonValue && IsSensitiveCyberSourceField(propertyPath, property.Key)) + jsonObject[property.Key] = JsonValue.Create(GetMaskedValue(propertyPath, jsonValue)); + else if (property.Value is not null) + SanitizeNode(property.Value, propertyPath); + } + + return; + } + + if (node is JsonArray jsonArray) + { + foreach (JsonNode item in jsonArray) + { + if (item is not null) + SanitizeNode(item, path); + } + } + } + + private static bool IsSensitiveCyberSourceField(string path, string propertyName) => + IsPaymentTokenPath(path) || + IsCardNumberPath(path) || + IsPaymentCryptogramPath(path) || + IsSecretFieldName(propertyName) || + PathEndsWith(path, "paymentInformation.fluidData.value") || + PathEndsWith(path, "tokenInformation.transientTokenJwt") || + PathEndsWith(path, "certificates.value"); + + private static bool IsPaymentTokenPath(string path) => + PathEquals(path, "paymentInformation.legacyToken.id") || + PathEquals(path, "paymentInformation.paymentInstrument.id") || + PathEquals(path, "paymentInformation.instrumentIdentifier.id") || + PathEquals(path, "paymentInformation.paymentAccountReference.id"); + + private static bool IsCardNumberPath(string path) => + PathEquals(path, "paymentInformation.card.number") || + PathEquals(path, "paymentInformation.tokenizedCard.number"); + + private static bool IsPaymentCryptogramPath(string path) => + PathEquals(path, "paymentInformation.tokenizedCard.cryptogram") || + PathEquals(path, "consumerAuthenticationInformation.cavv") || + PathEquals(path, "consumerAuthenticationInformation.ucafAuthenticationData") || + PathEquals(path, "consumerAuthenticationInformation.xid"); + + private static bool IsSecretFieldName(string propertyName) => + propertyName.Equals("securityCode", StringComparison.OrdinalIgnoreCase) || + propertyName.Equals("passPhrase", StringComparison.OrdinalIgnoreCase) || + propertyName.Equals("personalData", StringComparison.OrdinalIgnoreCase) || + propertyName.Contains("password", StringComparison.OrdinalIgnoreCase) || + propertyName.Contains("secret", StringComparison.OrdinalIgnoreCase); + + private static string GetMaskedValue(string path, JsonValue value) + { + if (IsCardNumberPath(path) && value.TryGetValue(out string cardNumber)) + return MaskIdentifier(cardNumber); + + return HiddenValue; + } + + private static bool PathEquals(string path, string expectedPath) => + string.Equals(path, expectedPath, StringComparison.OrdinalIgnoreCase); + + private static bool PathEndsWith(string path, string expectedPath) => + path.EndsWith(expectedPath, StringComparison.OrdinalIgnoreCase); + + private static string MaskIdentifier(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return HiddenValue; + + string trimmedValue = value.Trim(); + int visibleLength = Math.Min(4, trimmedValue.Length); + + return $"***{trimmedValue[^visibleLength..]}"; + } +} diff --git a/src/Service/CyberSourceRestAuthenticationSettings.cs b/src/Service/CyberSourceRestAuthenticationSettings.cs new file mode 100644 index 0000000..5cfd6e7 --- /dev/null +++ b/src/Service/CyberSourceRestAuthenticationSettings.cs @@ -0,0 +1,60 @@ +namespace Dynamicweb.Ecommerce.CheckoutHandlers.CyberSource.Service; + +/// +/// Contains the CyberSource REST API authentication settings used by saved-card payments and remote capture requests. +/// +/// +/// These settings are separate from the Secure Acceptance profile credentials used for the hosted checkout form. +/// REST requests authenticate either with a certificate-based JWT or with a REST Shared Secret JWT. +/// +internal sealed class CyberSourceRestAuthenticationSettings +{ + /// + /// Gets or sets the CyberSource merchant ID used as the REST API merchant identifier. + /// + public string MerchantId { get; set; } + + /// + /// Gets or sets the selected REST API authentication method. + /// + public RestAuthenticationMethod AuthenticationMethod { get; set; } + + /// + /// Gets or sets the selected REST API certificate file path. + /// + /// + /// Used only when is . + /// The file should point to the CyberSource REST certificate .p12 file stored in the Dynamicweb file archive. + /// + public string CertificateFile { get; set; } + + /// + /// Gets or sets the password used to open the selected REST API certificate file. + /// + /// + /// Used only when is . + /// This is the certificate password, not a Secure Acceptance profile secret. + /// + public string CertificatePassword { get; set; } + + /// + /// Gets or sets the REST Shared Secret key ID from CyberSource Business Center Key Management. + /// + /// + /// Used only when is . + /// + public string SharedSecretKeyId { get; set; } + + /// + /// Gets or sets the base64-encoded REST Shared Secret from CyberSource Business Center Key Management. + /// + /// + /// Used only when is . + /// + public string SharedSecret { get; set; } + + /// + /// Gets or sets a value indicating whether REST request and response diagnostics should be written to logs. + /// + public bool DebugLogging { get; set; } +} diff --git a/src/Service/CyberSourceService.cs b/src/Service/CyberSourceService.cs index 31ab46f..c0bed9a 100644 --- a/src/Service/CyberSourceService.cs +++ b/src/Service/CyberSourceService.cs @@ -13,9 +13,9 @@ internal sealed class CyberSourceService public string BaseAddress { get; } - public CyberSourceService(string baseAddress, string merchantId, string certificateFile, string certificatePassword) + public CyberSourceService(string baseAddress, CyberSourceRestAuthenticationSettings authenticationSettings) { - Request = new(merchantId, certificateFile, certificatePassword); + Request = new(authenticationSettings); BaseAddress = baseAddress; } From 7887fed6022b5b357814187a0b0a5178680af723 Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 08:23:50 +1000 Subject: [PATCH 02/10] Code review fixes --- src/CyberSource.cs | 2 +- src/Service/CyberSourceRequest.cs | 13 ++--- src/Service/CyberSourceRequestLogger.cs | 63 +++++++++++++++++++++---- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/CyberSource.cs b/src/CyberSource.cs index 952754b..d676f47 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -565,7 +565,7 @@ private OutputResult StateCancel(Order order) return OnError(order, "Wrong signature"); } - // CyberSource sends cancel as a cross-site POST, where storefront auth cookies can be withheld by dynamicweb site. + // CyberSource sends cancel as a cross-site POST, where storefront auth cookies can be withheld by Dynamicweb site. // Redirect to a same-site GET before CheckoutDone so the regular cart restoration can resolve the user context. return new RedirectOutputResult { diff --git a/src/Service/CyberSourceRequest.cs b/src/Service/CyberSourceRequest.cs index 3136e2a..d750aae 100644 --- a/src/Service/CyberSourceRequest.cs +++ b/src/Service/CyberSourceRequest.cs @@ -66,7 +66,8 @@ ApiCommand.CreatePayment or if (!response.IsSuccessStatusCode) { const string userReadableErrorMessage = "CyberSource payment request failed. See the order debug log for details."; - LogErrorResponse(requestLogger, response, responseText); + requestLogger.LogResponse(response, responseText, force: true); + LogErrorResponse(requestLogger, response, responseText, true); throw new Exception(userReadableErrorMessage); } @@ -75,7 +76,7 @@ ApiCommand.CreatePayment or } catch (HttpRequestException requestException) { - requestLogger.LogException(requestException); + requestLogger.LogException(requestException, force: true); throw new Exception("An error occurred during CyberSource request. See the order debug log for details.", requestException); } @@ -92,7 +93,7 @@ ApiCommand.CreatePayment or } } - private static void LogErrorResponse(CyberSourceRequestLogger requestLogger, HttpResponseMessage response, string responseText) + private static void LogErrorResponse(CyberSourceRequestLogger requestLogger, HttpResponseMessage response, string responseText, bool force) { try { @@ -100,7 +101,7 @@ private static void LogErrorResponse(CyberSourceRequestLogger requestLogger, Htt if (string.IsNullOrEmpty(errorResponse.Status)) { - requestLogger.LogParsedResponse($"Unhandled CyberSource response. Reason phrase: '{response.ReasonPhrase}'."); + requestLogger.LogParsedResponse($"Unhandled CyberSource response. Reason phrase: '{response.ReasonPhrase}'.", force); return; } @@ -114,12 +115,12 @@ private static void LogErrorResponse(CyberSourceRequestLogger requestLogger, Htt errorMessage += $" Details: '{detailsMessage.ToString()}'"; } - requestLogger.LogParsedResponse(errorMessage); + requestLogger.LogParsedResponse(errorMessage, force); } catch (Exception ex) { // The raw response is already logged; parsed error details are diagnostic-only. - requestLogger.LogException(ex); + requestLogger.LogException(ex, force); } } diff --git a/src/Service/CyberSourceRequestLogger.cs b/src/Service/CyberSourceRequestLogger.cs index 5b4cff7..8bd99dd 100644 --- a/src/Service/CyberSourceRequestLogger.cs +++ b/src/Service/CyberSourceRequestLogger.cs @@ -18,6 +18,8 @@ internal sealed class CyberSourceRequestLogger private readonly bool _enabled; private readonly Order _order; private readonly StringBuilder _logBuilder = new(); + private bool _hasHeader; + private bool _hasResponse; public CyberSourceRequestLogger(bool enabled, Order order) { @@ -27,11 +29,10 @@ public CyberSourceRequestLogger(bool enabled, Order order) public void LogRequest(HttpMethod method, string requestUri, string requestBody) { - if (!_enabled) + if (!ShouldLog(false)) return; - _logBuilder.AppendLine("CyberSource API Interaction Log:"); - _logBuilder.AppendLine(); + AppendHeader(); _logBuilder.AppendLine("--- REQUEST ---"); _logBuilder.Append(CultureInfo.InvariantCulture, $"Method: {method.Method}").AppendLine(); _logBuilder.Append(CultureInfo.InvariantCulture, $"URL: {requestUri}").AppendLine(); @@ -44,9 +45,18 @@ public void LogRequest(HttpMethod method, string requestUri, string requestBody) public void LogResponse(HttpResponseMessage response, string responseBody) { - if (!_enabled) + LogResponse(response, responseBody, false); + } + + public void LogResponse(HttpResponseMessage response, string responseBody, bool force) + { + if (!ShouldLog(force)) + return; + + if (_hasResponse) return; + AppendHeader(); _logBuilder.AppendLine(); _logBuilder.AppendLine("--- RESPONSE ---"); _logBuilder.Append(CultureInfo.InvariantCulture, $"HttpStatusCode: {(int)response.StatusCode} ({response.ReasonPhrase})").AppendLine(); @@ -54,13 +64,20 @@ public void LogResponse(HttpResponseMessage response, string responseBody) _logBuilder.AppendLine(); _logBuilder.AppendLine("--- RESPONSE BODY ---"); _logBuilder.AppendLine(SanitizeJsonForLog(responseBody)); + _hasResponse = true; } public void LogParsedResponse(string message) { - if (!_enabled) + LogParsedResponse(message, false); + } + + public void LogParsedResponse(string message, bool force) + { + if (!ShouldLog(force)) return; + AppendHeader(); _logBuilder.AppendLine(); _logBuilder.AppendLine("--- PARSED RESPONSE ---"); _logBuilder.AppendLine(SanitizeJsonForLog(message)); @@ -68,9 +85,15 @@ public void LogParsedResponse(string message) public void LogException(Exception exception) { - if (!_enabled) + LogException(exception, false); + } + + public void LogException(Exception exception, bool force) + { + if (!ShouldLog(force)) return; + AppendHeader(); _logBuilder.AppendLine(); _logBuilder.Append(CultureInfo.InvariantCulture, $"--- EXCEPTION ({exception.GetType().Name}) ---").AppendLine(); _logBuilder.AppendLine(SanitizeJsonForLog(exception.Message)); @@ -78,7 +101,7 @@ public void LogException(Exception exception) public void Flush() { - if (!_enabled || _logBuilder.Length == 0) + if (_logBuilder.Length == 0) return; _logBuilder.AppendLine(); @@ -92,6 +115,18 @@ public void Flush() Services.OrderDebuggingInfos.Save(_order, message, typeof(CyberSource).FullName, DebuggingInfoType.Undefined); } + private bool ShouldLog(bool force) => _enabled || force; + + private void AppendHeader() + { + if (_hasHeader) + return; + + _logBuilder.AppendLine("CyberSource API Interaction Log:"); + _logBuilder.AppendLine(); + _hasHeader = true; + } + internal static string SanitizeJsonForLog(string json) { if (string.IsNullOrWhiteSpace(json)) @@ -119,9 +154,17 @@ private static void SanitizeNode(JsonNode node, string path) foreach (var property in jsonObject.ToList()) { string propertyPath = string.IsNullOrEmpty(path) ? property.Key : $"{path}.{property.Key}"; - if (property.Value is JsonValue jsonValue && IsSensitiveCyberSourceField(propertyPath, property.Key)) - jsonObject[property.Key] = JsonValue.Create(GetMaskedValue(propertyPath, jsonValue)); - else if (property.Value is not null) + + if (IsSensitiveCyberSourceField(propertyPath, property.Key)) + { + jsonObject[property.Key] = property.Value is JsonValue jsonValue + ? JsonValue.Create(GetMaskedValue(propertyPath, jsonValue)) + : JsonValue.Create(HiddenValue); + + continue; + } + + if (property.Value is not null) SanitizeNode(property.Value, propertyPath); } From 56147aaa0aeebcda6686ae68922ac31c73cde958 Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 08:57:36 +1000 Subject: [PATCH 03/10] Code review fixes --- src/CyberSource.cs | 38 ++++++++++++++++++++++++++++--- src/Service/CyberSourceRequest.cs | 12 ++++++++-- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/CyberSource.cs b/src/CyberSource.cs index d676f47..9bb5178 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -249,9 +249,28 @@ private bool ValidateRestApiSettings(out string errorMessage) return false; } + if (!IsValidBase64(RestSharedSecret)) + { + errorMessage = "REST Shared Secret must be a valid base64-encoded value"; + return false; + } + return true; } + private static bool IsValidBase64(string value) + { + try + { + Convert.FromBase64String(value.Trim()); + return true; + } + catch (FormatException) + { + return false; + } + } + private CyberSourceRestAuthenticationSettings GetRestAuthenticationSettings() => new() { MerchantId = MerchantId, @@ -565,6 +584,8 @@ private OutputResult StateCancel(Order order) return OnError(order, "Wrong signature"); } + MarkOrderAsCancelled(order); + // CyberSource sends cancel as a cross-site POST, where storefront auth cookies can be withheld by Dynamicweb site. // Redirect to a same-site GET before CheckoutDone so the regular cart restoration can resolve the user context. return new RedirectOutputResult @@ -583,9 +604,13 @@ private OutputResult StateCancelReturn(Order order) return NotFoundOutputResult.Default; } - order.TransactionAmount = 0; - order.TransactionStatus = "Cancelled"; - Services.Orders.Save(order); + if (order.Complete) + { + LogEvent(order, "Cancel return ignored because order is already complete."); + return NotFoundOutputResult.Default; + } + + MarkOrderAsCancelled(order); CheckoutDone(order); var cancelTemplate = new Template(TemplateHelper.GetTemplatePath(CancelTemplate, CancelTemplateFolder)); @@ -596,6 +621,13 @@ private OutputResult StateCancelReturn(Order order) }; } + private static void MarkOrderAsCancelled(Order order) + { + order.TransactionAmount = 0; + order.TransactionStatus = "Cancelled"; + Services.Orders.Save(order); + } + private OutputResult StateIFrameError(Order order) { string errorMessage = Context.Current.Request["ErrorMessage"]; diff --git a/src/Service/CyberSourceRequest.cs b/src/Service/CyberSourceRequest.cs index d750aae..6c04f71 100644 --- a/src/Service/CyberSourceRequest.cs +++ b/src/Service/CyberSourceRequest.cs @@ -47,10 +47,18 @@ ApiCommand.CreatePayment or _ => throw new NotSupportedException($"Unknown http method was used: {method.ToString()}.") }; - AddAuthenticationHeaders(request, host, data); - try { + try + { + AddAuthenticationHeaders(request, host, data); + } + catch (Exception exception) + { + requestLogger.LogException(exception, force: true); + throw new Exception("An error occurred during CyberSource request authentication. See the order debug log for details.", exception); + } + using HttpResponseMessage response = Client .SendAsync(request) .GetAwaiter() From b90ffc7cfb652a148cffc71b1c918d0339f11dec Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 09:38:18 +1000 Subject: [PATCH 04/10] Code review fixes --- src/CyberSource.cs | 8 +++++++- src/Service/CyberSourceRequestLogger.cs | 12 ++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/CyberSource.cs b/src/CyberSource.cs index 9bb5178..23cffc3 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -113,7 +113,7 @@ public string RestAuthenticationMethodName get => restAuthenticationMethod.ToString(); set { - if (Enum.TryParse(value, out RestAuthenticationMethod parsedMethod)) + if (Enum.TryParse(value, out RestAuthenticationMethod parsedMethod) && Enum.IsDefined(parsedMethod)) restAuthenticationMethod = parsedMethod; } } @@ -584,6 +584,12 @@ private OutputResult StateCancel(Order order) return OnError(order, "Wrong signature"); } + if (order.Complete) + { + LogEvent(order, "Cancel ignored because order is already complete."); + return NotFoundOutputResult.Default; + } + MarkOrderAsCancelled(order); // CyberSource sends cancel as a cross-site POST, where storefront auth cookies can be withheld by Dynamicweb site. diff --git a/src/Service/CyberSourceRequestLogger.cs b/src/Service/CyberSourceRequestLogger.cs index 8bd99dd..718e365 100644 --- a/src/Service/CyberSourceRequestLogger.cs +++ b/src/Service/CyberSourceRequestLogger.cs @@ -95,8 +95,7 @@ public void LogException(Exception exception, bool force) AppendHeader(); _logBuilder.AppendLine(); - _logBuilder.Append(CultureInfo.InvariantCulture, $"--- EXCEPTION ({exception.GetType().Name}) ---").AppendLine(); - _logBuilder.AppendLine(SanitizeJsonForLog(exception.Message)); + AppendException(exception, "EXCEPTION"); } public void Flush() @@ -127,6 +126,15 @@ private void AppendHeader() _hasHeader = true; } + private void AppendException(Exception exception, string header) + { + _logBuilder.Append(CultureInfo.InvariantCulture, $"--- {header} ({exception.GetType().Name}) ---").AppendLine(); + _logBuilder.AppendLine(SanitizeJsonForLog(exception.Message)); + + if (exception.InnerException is not null) + AppendException(exception.InnerException, "INNER EXCEPTION"); + } + internal static string SanitizeJsonForLog(string json) { if (string.IsNullOrWhiteSpace(json)) From fe71b0027df2f45228233231d1e267a9d285be97 Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 18:47:49 +1000 Subject: [PATCH 05/10] Code review fixes --- src/CyberSource.cs | 23 +++++++++++--- src/Helpers/Helper.cs | 51 ++++++++++++++++++------------- src/Service/CyberSourceRequest.cs | 6 ++++ src/Service/CyberSourceService.cs | 3 +- 4 files changed, 54 insertions(+), 29 deletions(-) diff --git a/src/CyberSource.cs b/src/CyberSource.cs index 23cffc3..c1095aa 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -30,6 +30,7 @@ public class CyberSource : CheckoutHandlerWithStatusPage, IParameterOptions, IPa private const string FormTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Payment"; private const string CancelTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Cancel"; private const string ErrorTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Error"; + private const string CancelReturnCommand = "CancelReturn"; private const string CancelReturnTokenParameterName = "CancelReturnToken"; private const string RestAuthenticationMethodParameterName = "REST authentication method"; @@ -37,6 +38,7 @@ public class CyberSource : CheckoutHandlerWithStatusPage, IParameterOptions, IPa private const string CertificatePasswordParameterName = "Certificate password"; private const string RestSharedSecretKeyIdParameterName = "REST Shared Secret Key ID"; private const string RestSharedSecretParameterName = "REST Shared Secret"; + private const string CancelledTransactionStatusName = "Cancelled"; private static readonly HashSet SupportedCountryCodes; private static readonly HashSet SupportedCurrencyCodes; @@ -590,7 +592,7 @@ private OutputResult StateCancel(Order order) return NotFoundOutputResult.Default; } - MarkOrderAsCancelled(order); + EnsureOrderMarkedAsCancelled(order); // CyberSource sends cancel as a cross-site POST, where storefront auth cookies can be withheld by Dynamicweb site. // Redirect to a same-site GET before CheckoutDone so the regular cart restoration can resolve the user context. @@ -616,7 +618,7 @@ private OutputResult StateCancelReturn(Order order) return NotFoundOutputResult.Default; } - MarkOrderAsCancelled(order); + EnsureOrderMarkedAsCancelled(order); CheckoutDone(order); var cancelTemplate = new Template(TemplateHelper.GetTemplatePath(CancelTemplate, CancelTemplateFolder)); @@ -630,10 +632,22 @@ private OutputResult StateCancelReturn(Order order) private static void MarkOrderAsCancelled(Order order) { order.TransactionAmount = 0; - order.TransactionStatus = "Cancelled"; + order.TransactionStatus = CancelledTransactionStatusName; Services.Orders.Save(order); } + private static void EnsureOrderMarkedAsCancelled(Order order) + { + if (IsOrderMarkedAsCancelled(order)) + return; + + MarkOrderAsCancelled(order); + } + + private static bool IsOrderMarkedAsCancelled(Order order) => + order.TransactionAmount == 0 && + string.Equals(order.TransactionStatus, CancelledTransactionStatusName, StringComparison.OrdinalIgnoreCase); + private OutputResult StateIFrameError(Order order) { string errorMessage = Context.Current.Request["ErrorMessage"]; @@ -876,8 +890,7 @@ private OutputResult OnError(Order order, string message, bool isIFrameError = f private Dictionary PrepareRequest(Order order, string requestTransactionType, string token) { string customerName = order.CustomerName?.Trim() ?? ""; - string firstName = Helper.GetCustomerFirstName(order, customerName); - string lastName = Helper.GetCustomerLastName(order, customerName); + (string firstName, string lastName) = Helper.GetCustomerNameParts(order, customerName); string amount = transactionType is TransactionTypes.ZeroAuthorization ? "0.00" : Helper.GetTransactionAmount(order); diff --git a/src/Helpers/Helper.cs b/src/Helpers/Helper.cs index eb244f6..86d691d 100644 --- a/src/Helpers/Helper.cs +++ b/src/Helpers/Helper.cs @@ -24,30 +24,16 @@ public static string GetCertificateFilePath(string certificateFile) return string.Empty; } - public static string GetCustomerLastName(Order order, string customerName) + public static (string FirstName, string LastName) GetCustomerNameParts(Order order, string customerName) { - string lastName = order.CustomerSurname; - int delimiterPosition = customerName.IndexOf(' '); - if (string.IsNullOrWhiteSpace(lastName)) - { - lastName = delimiterPosition > -1 - ? customerName.Substring(delimiterPosition + 1) - : customerName; - } + User user = GetOrderUser(order); + string firstName = GetCustomerFirstName(order, customerName, user); + string lastName = GetCustomerLastName(order, customerName, user); - if (string.IsNullOrWhiteSpace(lastName)) - { - User user = GetOrderUser(order); - lastName = GetFirstValue(user?.LastName, - GetLastName(user?.Name), - GetLastName(user?.UserName), - GetFallbackCustomerName(order, user)); - } + return (firstName, lastName); + } - return lastName; - } - - public static string GetCustomerFirstName(Order order, string customerName) + private static string GetCustomerFirstName(Order order, string customerName, User user) { string firstName = order.CustomerFirstName; int delimiterPosition = customerName.IndexOf(' '); @@ -60,7 +46,6 @@ public static string GetCustomerFirstName(Order order, string customerName) if (string.IsNullOrWhiteSpace(firstName)) { - User user = GetOrderUser(order); firstName = GetFirstValue(user?.FirstName, GetFirstName(user?.Name), GetFirstName(user?.UserName), @@ -70,6 +55,28 @@ public static string GetCustomerFirstName(Order order, string customerName) return firstName; } + private static string GetCustomerLastName(Order order, string customerName, User user) + { + string lastName = order.CustomerSurname; + int delimiterPosition = customerName.IndexOf(' '); + if (string.IsNullOrWhiteSpace(lastName)) + { + lastName = delimiterPosition > -1 + ? customerName.Substring(delimiterPosition + 1) + : customerName; + } + + if (string.IsNullOrWhiteSpace(lastName)) + { + lastName = GetFirstValue(user?.LastName, + GetLastName(user?.Name), + GetLastName(user?.UserName), + GetFallbackCustomerName(order, user)); + } + + return lastName; + } + public static string GetTransactionAmount(Order order) { int decimals = order.Currency.Rounding is null || string.IsNullOrEmpty(order.Currency.Rounding.Id) diff --git a/src/Service/CyberSourceRequest.cs b/src/Service/CyberSourceRequest.cs index 6c04f71..cc0924c 100644 --- a/src/Service/CyberSourceRequest.cs +++ b/src/Service/CyberSourceRequest.cs @@ -88,6 +88,12 @@ ApiCommand.CreatePayment or throw new Exception("An error occurred during CyberSource request. See the order debug log for details.", requestException); } + catch (OperationCanceledException requestException) + { + requestLogger.LogException(requestException, force: true); + + throw new Exception("An error occurred during CyberSource request. See the order debug log for details.", requestException); + } finally { try diff --git a/src/Service/CyberSourceService.cs b/src/Service/CyberSourceService.cs index c0bed9a..113377c 100644 --- a/src/Service/CyberSourceService.cs +++ b/src/Service/CyberSourceService.cs @@ -64,8 +64,7 @@ public CaptureResponse Capture(Order order, string transactionNumber) private PaymentRequestData PreparePaymentRequest(Order order, PaymentCardToken savedCard) { string customerName = order.CustomerName?.Trim() ?? string.Empty; - string firstName = Helper.GetCustomerFirstName(order, customerName); - string lastName = Helper.GetCustomerLastName(order, customerName); + (string firstName, string lastName) = Helper.GetCustomerNameParts(order, customerName); return new() { From b5a601dc862b3b2268c0881a6cff89ac9136c175 Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 20:52:00 +1000 Subject: [PATCH 06/10] Code review fixes. --- src/CyberSource.cs | 6 ++--- src/Helpers/Helper.cs | 54 ++++++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/src/CyberSource.cs b/src/CyberSource.cs index c1095aa..fae85e9 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -275,12 +275,12 @@ private static bool IsValidBase64(string value) private CyberSourceRestAuthenticationSettings GetRestAuthenticationSettings() => new() { - MerchantId = MerchantId, + MerchantId = MerchantId?.Trim(), AuthenticationMethod = restAuthenticationMethod, CertificateFile = CertificateFile, CertificatePassword = CertificatePassword, - SharedSecretKeyId = RestSharedSecretKeyId, - SharedSecret = RestSharedSecret, + SharedSecretKeyId = RestSharedSecretKeyId?.Trim(), + SharedSecret = RestSharedSecret?.Trim(), DebugLogging = DebugLogging }; diff --git a/src/Helpers/Helper.cs b/src/Helpers/Helper.cs index 86d691d..570bc69 100644 --- a/src/Helpers/Helper.cs +++ b/src/Helpers/Helper.cs @@ -26,14 +26,20 @@ public static string GetCertificateFilePath(string certificateFile) public static (string FirstName, string LastName) GetCustomerNameParts(Order order, string customerName) { - User user = GetOrderUser(order); - string firstName = GetCustomerFirstName(order, customerName, user); - string lastName = GetCustomerLastName(order, customerName, user); + string firstName = GetCustomerFirstName(order, customerName); + string lastName = GetCustomerLastName(order, customerName); + + if (string.IsNullOrWhiteSpace(firstName) || string.IsNullOrWhiteSpace(lastName)) + { + User user = GetOrderUser(order); + firstName = GetCustomerFirstNameFallback(order, user, firstName); + lastName = GetCustomerLastNameFallback(order, user, lastName); + } return (firstName, lastName); } - private static string GetCustomerFirstName(Order order, string customerName, User user) + private static string GetCustomerFirstName(Order order, string customerName) { string firstName = order.CustomerFirstName; int delimiterPosition = customerName.IndexOf(' '); @@ -44,18 +50,10 @@ private static string GetCustomerFirstName(Order order, string customerName, Use : customerName; } - if (string.IsNullOrWhiteSpace(firstName)) - { - firstName = GetFirstValue(user?.FirstName, - GetFirstName(user?.Name), - GetFirstName(user?.UserName), - GetFallbackCustomerName(order, user)); - } - return firstName; } - private static string GetCustomerLastName(Order order, string customerName, User user) + private static string GetCustomerLastName(Order order, string customerName) { string lastName = order.CustomerSurname; int delimiterPosition = customerName.IndexOf(' '); @@ -66,17 +64,31 @@ private static string GetCustomerLastName(Order order, string customerName, User : customerName; } - if (string.IsNullOrWhiteSpace(lastName)) - { - lastName = GetFirstValue(user?.LastName, - GetLastName(user?.Name), - GetLastName(user?.UserName), - GetFallbackCustomerName(order, user)); - } - return lastName; } + private static string GetCustomerFirstNameFallback(Order order, User user, string firstName) + { + if (!string.IsNullOrWhiteSpace(firstName)) + return firstName; + + return GetFirstValue(user?.FirstName, + GetFirstName(user?.Name), + GetFirstName(user?.UserName), + GetFallbackCustomerName(order, user)); + } + + private static string GetCustomerLastNameFallback(Order order, User user, string lastName) + { + if (!string.IsNullOrWhiteSpace(lastName)) + return lastName; + + return GetFirstValue(user?.LastName, + GetLastName(user?.Name), + GetLastName(user?.UserName), + GetFallbackCustomerName(order, user)); + } + public static string GetTransactionAmount(Order order) { int decimals = order.Currency.Rounding is null || string.IsNullOrEmpty(order.Currency.Rounding.Id) From e798127a0a9cc2e5c135a6979433a9a8c8d23900 Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 21:34:21 +1000 Subject: [PATCH 07/10] Code review fixes --- src/CyberSource.cs | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/src/CyberSource.cs b/src/CyberSource.cs index fae85e9..4468b86 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -33,12 +33,14 @@ public class CyberSource : CheckoutHandlerWithStatusPage, IParameterOptions, IPa private const string CancelReturnCommand = "CancelReturn"; private const string CancelReturnTokenParameterName = "CancelReturnToken"; + private const string CancelReturnTimeParameterName = "CancelReturnTime"; private const string RestAuthenticationMethodParameterName = "REST authentication method"; private const string CertificateParameterName = "Certificate"; private const string CertificatePasswordParameterName = "Certificate password"; private const string RestSharedSecretKeyIdParameterName = "REST Shared Secret Key ID"; private const string RestSharedSecretParameterName = "REST Shared Secret"; private const string CancelledTransactionStatusName = "Cancelled"; + private static readonly TimeSpan CancelReturnTokenLifetime = TimeSpan.FromMinutes(5); private static readonly HashSet SupportedCountryCodes; private static readonly HashSet SupportedCurrencyCodes; @@ -363,7 +365,7 @@ public IEnumerable GetHiddenParameterNames(string parameterName, object return Enumerable.Empty(); RestAuthenticationMethod selectedMethod = restAuthenticationMethod; - if (parameterValue is not null && Enum.TryParse(parameterValue.ToString(), out RestAuthenticationMethod parsedMethod)) + if (parameterValue is not null && Enum.TryParse(parameterValue.ToString(), out RestAuthenticationMethod parsedMethod) && Enum.IsDefined(parsedMethod)) selectedMethod = parsedMethod; return selectedMethod is RestAuthenticationMethod.SharedSecret @@ -578,6 +580,13 @@ private bool ValidateOrderFields(Order order, out string errorMessage) private OutputResult StateCancel(Order order) { LogEvent(order, "State cancel"); + + if (order.Complete) + { + LogEvent(order, "Cancel ignored because order is already complete."); + return NotFoundOutputResult.Default; + } + string calculatedSignature; if (windowMode is not WindowModes.Embedded && !SecurityHelper.ValidateResponseSignation(AccessKey, SecretKey, out calculatedSignature)) { @@ -586,12 +595,6 @@ private OutputResult StateCancel(Order order) return OnError(order, "Wrong signature"); } - if (order.Complete) - { - LogEvent(order, "Cancel ignored because order is already complete."); - return NotFoundOutputResult.Default; - } - EnsureOrderMarkedAsCancelled(order); // CyberSource sends cancel as a cross-site POST, where storefront auth cookies can be withheld by Dynamicweb site. @@ -1010,27 +1013,43 @@ private string GetAcceptUrl(Order order) private string GetCancelReturnUrl(Order order) { - string cancelReturnToken = WebUtility.UrlEncode(GetCancelReturnToken(order)); + string cancelReturnTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture); + string cancelReturnToken = WebUtility.UrlEncode(GetCancelReturnToken(order, cancelReturnTime)); - return $"{GetBaseUrl(order)}&cmd={CancelReturnCommand}&{CancelReturnTokenParameterName}={cancelReturnToken}"; + return $"{GetBaseUrl(order)}&cmd={CancelReturnCommand}&{CancelReturnTimeParameterName}={cancelReturnTime}&{CancelReturnTokenParameterName}={cancelReturnToken}"; } private bool ValidateCancelReturnToken(Order order) { string cancelReturnToken = Context.Current.Request[CancelReturnTokenParameterName]; + string cancelReturnTime = Context.Current.Request[CancelReturnTimeParameterName]; return !string.IsNullOrEmpty(cancelReturnToken) - && string.Equals(cancelReturnToken, GetCancelReturnToken(order), StringComparison.Ordinal); + && IsValidCancelReturnTime(cancelReturnTime) + && string.Equals(cancelReturnToken, GetCancelReturnToken(order, cancelReturnTime), StringComparison.Ordinal); + } + + private static bool IsValidCancelReturnTime(string cancelReturnTime) + { + long? unixTimeSeconds = Core.Converter.ToNullableInt64(cancelReturnTime); + if (!unixTimeSeconds.HasValue) + return false; + + DateTimeOffset tokenTime = DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds.Value); + TimeSpan tokenAge = DateTimeOffset.UtcNow - tokenTime; + + return tokenAge >= TimeSpan.Zero && tokenAge <= CancelReturnTokenLifetime; } - private string GetCancelReturnToken(Order order) + private string GetCancelReturnToken(Order order, string cancelReturnTime) { var parameters = new Dictionary { ["order_id"] = order.Id ?? string.Empty, ["order_secret"] = order.Secret ?? string.Empty, ["cmd"] = CancelReturnCommand, - ["signed_field_names"] = "order_id,order_secret,cmd" + ["cancel_return_time"] = cancelReturnTime, + ["signed_field_names"] = "order_id,order_secret,cmd,cancel_return_time" }; return SecurityHelper.Sign(parameters, SecretKey); From 66073feb5c4f44e014e2af86ffe9e02878c946c1 Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 21:48:03 +1000 Subject: [PATCH 08/10] Code review fixes --- src/CyberSource.cs | 4 ++++ src/Helpers/JwtAuthenticationHelper.cs | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/CyberSource.cs b/src/CyberSource.cs index 4468b86..cbb19a1 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -1035,6 +1035,10 @@ private static bool IsValidCancelReturnTime(string cancelReturnTime) if (!unixTimeSeconds.HasValue) return false; + if (unixTimeSeconds.Value < DateTimeOffset.MinValue.ToUnixTimeSeconds() || + unixTimeSeconds.Value > DateTimeOffset.MaxValue.ToUnixTimeSeconds()) + return false; + DateTimeOffset tokenTime = DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds.Value); TimeSpan tokenAge = DateTimeOffset.UtcNow - tokenTime; diff --git a/src/Helpers/JwtAuthenticationHelper.cs b/src/Helpers/JwtAuthenticationHelper.cs index 3d0a32a..e970598 100644 --- a/src/Helpers/JwtAuthenticationHelper.cs +++ b/src/Helpers/JwtAuthenticationHelper.cs @@ -41,7 +41,7 @@ public static string GenerateCertificateToken(string merchantId, string certific } catch (Exception ex) { - throw new Exception("Certificate JWT token create failed", ex); + throw new Exception("Certificate JWT token creation failed", ex); } } @@ -78,7 +78,7 @@ public static string GenerateSharedSecretToken(string merchantId, string sharedS } catch (Exception ex) { - throw new Exception("Shared Secret JWT token create failed", ex); + throw new Exception("Shared Secret JWT token creation failed", ex); } } From 2f448192f687aa7082b8850ac9edb310887210ff Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 22:01:36 +1000 Subject: [PATCH 09/10] Fixed bug in the GetCustomerLastName() --- src/Helpers/Helper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Helpers/Helper.cs b/src/Helpers/Helper.cs index 570bc69..10a2b4a 100644 --- a/src/Helpers/Helper.cs +++ b/src/Helpers/Helper.cs @@ -61,7 +61,7 @@ private static string GetCustomerLastName(Order order, string customerName) { lastName = delimiterPosition > -1 ? customerName.Substring(delimiterPosition + 1) - : customerName; + : string.Empty; } return lastName; From 6cbab95899a8066870ff6364b86bbba0e7834041 Mon Sep 17 00:00:00 2001 From: Stanislav Smetanin Date: Fri, 15 May 2026 22:14:53 +1000 Subject: [PATCH 10/10] Cosmetic fixes --- src/CyberSource.cs | 6 +++--- src/Service/CyberSourceRequest.cs | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/CyberSource.cs b/src/CyberSource.cs index cbb19a1..6557ad7 100644 --- a/src/CyberSource.cs +++ b/src/CyberSource.cs @@ -10,9 +10,9 @@ using Dynamicweb.Rendering; using Dynamicweb.Security.UserManagement; using System; +using System.Collections.Frozen; using System.Collections.Generic; using System.Globalization; -using System.Collections.Frozen; using System.Linq; using System.Net; using System.Threading; @@ -30,7 +30,7 @@ public class CyberSource : CheckoutHandlerWithStatusPage, IParameterOptions, IPa private const string FormTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Payment"; private const string CancelTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Cancel"; private const string ErrorTemplateFolder = "eCom7/CheckoutHandler/CyberSource/Error"; - + private const string CancelReturnCommand = "CancelReturn"; private const string CancelReturnTokenParameterName = "CancelReturnToken"; private const string CancelReturnTimeParameterName = "CancelReturnTime"; @@ -1035,7 +1035,7 @@ private static bool IsValidCancelReturnTime(string cancelReturnTime) if (!unixTimeSeconds.HasValue) return false; - if (unixTimeSeconds.Value < DateTimeOffset.MinValue.ToUnixTimeSeconds() || + if (unixTimeSeconds.Value < DateTimeOffset.MinValue.ToUnixTimeSeconds() || unixTimeSeconds.Value > DateTimeOffset.MaxValue.ToUnixTimeSeconds()) return false; diff --git a/src/Service/CyberSourceRequest.cs b/src/Service/CyberSourceRequest.cs index cc0924c..5293a26 100644 --- a/src/Service/CyberSourceRequest.cs +++ b/src/Service/CyberSourceRequest.cs @@ -44,7 +44,7 @@ ApiCommand.CreatePayment or request.Content = method switch { _ when method == HttpMethod.Post => GetContent(data), - _ => throw new NotSupportedException($"Unknown http method was used: {method.ToString()}.") + _ => throw new NotSupportedException($"Unknown HTTP method was used: {method.ToString()}.") }; try @@ -146,14 +146,14 @@ private void AddAuthenticationHeaders(HttpRequestMessage request, string host, s string jwtToken = AuthenticationSettings.AuthenticationMethod switch { RestAuthenticationMethod.Certificate => JwtAuthenticationHelper.GenerateCertificateToken( - AuthenticationSettings.MerchantId, - AuthenticationSettings.CertificateFile, - AuthenticationSettings.CertificatePassword, + AuthenticationSettings.MerchantId, + AuthenticationSettings.CertificateFile, + AuthenticationSettings.CertificatePassword, request.Method, host, resourcePath, data), RestAuthenticationMethod.SharedSecret => JwtAuthenticationHelper.GenerateSharedSecretToken( AuthenticationSettings.MerchantId, AuthenticationSettings.SharedSecretKeyId, - AuthenticationSettings.SharedSecret, + AuthenticationSettings.SharedSecret, request.Method, host, resourcePath, data), _ => throw new NotSupportedException($"Unsupported REST authentication method: {AuthenticationSettings.AuthenticationMethod}") };