diff --git a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/AuthenticationSdk.csproj b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/AuthenticationSdk.csproj index 09258205..2dff2cbb 100644 --- a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/AuthenticationSdk.csproj +++ b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/AuthenticationSdk.csproj @@ -36,6 +36,7 @@ + diff --git a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/Cache.cs b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/Cache.cs index 43517fd2..93898da6 100644 --- a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/Cache.cs +++ b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/Cache.cs @@ -236,5 +236,32 @@ private static X509Certificate2Collection FetchCertificateCollectionFromP12File( //return all certs in p12 return certificates; } + + public static void AddPublicKeyToCache(string publickey, string runEnvironment, string kid) + { + // Construct cache key similar to PHP logic + string cacheKey = $"{Constants.PUBLIC_KEY_CACHE_IDENTIFIER}_{runEnvironment}_{kid}"; + + ObjectCache cache = MemoryCache.Default; + + var policy = new CacheItemPolicy(); + // Optionally, set expiration or change monitors if needed + + lock (mutex) + { + cache.Set(cacheKey, publickey, policy); + } + } + public static string GetPublicKeyFromCache(string runEnvironment, string keyId) + { + string cacheKey = $"{Constants.PUBLIC_KEY_CACHE_IDENTIFIER}_{runEnvironment}_{keyId}"; + ObjectCache cache = MemoryCache.Default; + + if (cache.Contains(cacheKey)) + { + return cache.Get(cacheKey) as string; + } + throw new Exception($"Public key not found in cache for [RunEnvironment: {runEnvironment}, KeyId: {keyId}]"); + } } } diff --git a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/Constants.cs b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/Constants.cs index 65861570..a16fa1eb 100644 --- a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/Constants.cs +++ b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/Constants.cs @@ -51,5 +51,7 @@ public static class Constants public static readonly string MLE_CACHE_IDENTIFIER_FOR_CONFIG_CERT = "mleCertFromMerchantConfig"; public static readonly string MLE_CACHE_IDENTIFIER_FOR_P12_CERT = "mleCertFromP12"; + + public static readonly string PUBLIC_KEY_CACHE_IDENTIFIER = "FlexV2PublicKeys"; } } diff --git a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/JWTUtility.cs b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/JWTUtility.cs new file mode 100644 index 00000000..1937fa79 --- /dev/null +++ b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/JWTUtility.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using Jose; +using Microsoft.IdentityModel.Tokens; +using Newtonsoft.Json; +using AuthenticationSdk.util.jwtExceptions; + +namespace AuthenticationSdk.util +{ + public static class JWTUtility + { + + + /// + /// Parses a JWT token to verify its structure and decodes its payload, without performing signature validation. + /// This is useful for inspecting the token's claims before verifying its authenticity. + /// + /// The JWT token string to parse. + /// The JSON payload of the token as a string if the token is structurally valid. + /// Thrown if the token is null, empty, malformed, or not a valid JWT structure. + public static string Parse(string jwtToken) + { + if (string.IsNullOrWhiteSpace(jwtToken)) + { + throw new InvalidJwtException("JWT token cannot be null, empty, or whitespace."); + } + + try + { + // The jose-jwt library's Payload method handles splitting the token and Base64Url decoding the payload part. + // It will throw an exception if the token does not have three parts or if the payload is not valid Base64Url. + string payloadJson = JWT.Payload(jwtToken); + + // The JWT specification requires the payload (Claims Set) to be a JSON object. + // We'll verify this to ensure the token is fully compliant. + try + { + JsonConvert.DeserializeObject(payloadJson); + } + catch (JsonException jsonEx) + { + throw new JsonException("Invalid JWT: The payload is not a valid JSON object.", jsonEx); + } + + // If all checks pass, return the decoded payload. + return payloadJson; + } + catch (JsonException) + { + // Rethrow JSON exceptions as they are. + throw; + } + catch (Exception ex) + { + // Catch exceptions from JWT.Payload() (e.g., malformed token) + throw new InvalidJwtException("The provided JWT is malformed.", ex); + } + } + + public static IDictionary GetJwtHeaders(string jwtToken) + { + return JWT.Headers(jwtToken); + } + + /// + /// Verifies a JWT token against a public key provided as a JWK string. + /// + /// The JWT token to verify. + /// The public key in JWK JSON format. + /// Returns true if the token is successfully verified. + /// + /// Throws an exception if verification fails due to an invalid signature, + /// a malformed token, a missing algorithm header, or other errors. + /// + /// + /// Thrown if verification fails due to an invalid signature, malformed token, missing algorithm header, or other errors. + /// + /// + /// Thrown if the JWT header is missing the 'alg' parameter or the algorithm is not supported. + /// + /// + /// Thrown if the JWK is invalid, not an RSA key, or missing required fields. + /// + public static bool VerifyJwt(string jwtValue, string publicKey) + { + try + { + // Step 1: Convert the JWK string into RSA parameters. + RSAParameters rsaParameters = ConvertJwkToRsaParameters(publicKey); + + // Step 2: Create an RSACryptoServiceProvider and import the public key. + var rsa = new RSACryptoServiceProvider(); + rsa.ImportParameters(rsaParameters); + + // Step 3: Dynamically determine the algorithm from the JWT's header. + var headers = JWT.Headers(jwtValue); + if (!headers.TryGetValue("alg", out var alg)) + { + throw new ArgumentException("JWT header is missing the 'alg' parameter."); + } + + string algStr = alg as string; + var supportedRsaAlgorithms = new[] { "RS256", "RS384", "RS512" }; + if (Array.IndexOf(supportedRsaAlgorithms, algStr) < 0) + { + throw new ArgumentException($"The algorithm in the JWT token is not RSA. Only {string.Join(", ", supportedRsaAlgorithms)} are supported."); + } + + // Parse the string algorithm into the JwsAlgorithm enum. + var jwsAlgorithm = (JwsAlgorithm)Enum.Parse(typeof(JwsAlgorithm), algStr); + + // Step 4: Decode and verify the token. + // The JWT.Decode method will perform signature validation and throw + // a Jose.IntegrityException if the signature is invalid. + JWT.Decode(jwtValue, rsa, jwsAlgorithm); + + // Step 5: If JWT.Decode completes without throwing an exception, verification is successful. + return true; + } + catch (JoseException ex) + { + // This will catch signature validation errors (IntegrityException) + // or other JWT-specific issues from the jose-jwt library. + // Re-throwing as a general exception to signal verification failure. + throw new JwtSignatureValidationException("JWT verification failed. See inner exception for details.", ex); + } + catch (ArgumentException) + { + throw; + } + catch (InvalidJwkException) + { + throw; + } + catch (Exception ex) + { + // This catches other potential errors, such as from JWK conversion or invalid algorithm parsing. + throw new JwtSignatureValidationException("An unexpected error occurred during JWT verification.", ex); + } + } + + /// + /// Converts a JSON Web Key (JWK) string into RSAParameters. + /// This method is designed for RSA public keys. + /// + /// The JWK in JSON string format. + /// An RSAParameters object containing the public key. + /// + /// Thrown if the JWK is invalid, not an RSA key, or missing required fields. + /// + private static RSAParameters ConvertJwkToRsaParameters(string jwkJson) + { + Dictionary jwk; + try + { + jwk = JsonConvert.DeserializeObject>(jwkJson); + } + catch (JsonException ex) + { + throw new InvalidJwkException("Malformed JWK: Not valid JSON format", ex); + } + + if (jwk == null || !jwk.ContainsKey("kty") || jwk["kty"] != "RSA" || !jwk.ContainsKey("n") || !jwk.ContainsKey("e")) + { + throw new InvalidJwkException("Invalid JWK: Must be an RSA key with 'kty', 'n', and 'e' values."); + } + + // Use the standard library for Base64Url decoding + byte[] modulus = Base64UrlEncoder.DecodeBytes(jwk["n"]); + byte[] exponent = Base64UrlEncoder.DecodeBytes(jwk["e"]); + + return new RSAParameters + { + Modulus = modulus, + Exponent = exponent + }; + } + } +} diff --git a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/jwtExceptions/InvalidJwkException.cs b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/jwtExceptions/InvalidJwkException.cs new file mode 100644 index 00000000..e7124665 --- /dev/null +++ b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/jwtExceptions/InvalidJwkException.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AuthenticationSdk.util.jwtExceptions +{ + public class InvalidJwkException : Exception + { + public InvalidJwkException(string message) : base(message) { } + public InvalidJwkException(string message, Exception cause) : base(message, cause) { } + } +} diff --git a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/jwtExceptions/InvalidJwtException.cs b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/jwtExceptions/InvalidJwtException.cs new file mode 100644 index 00000000..0f732f70 --- /dev/null +++ b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/jwtExceptions/InvalidJwtException.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AuthenticationSdk.util.jwtExceptions +{ + public class InvalidJwtException : Exception + { + public InvalidJwtException(string message) : base(message) { } + public InvalidJwtException(string message, Exception cause) : base(message, cause) { } + } +} diff --git a/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/jwtExceptions/JwtSignatureValidationException.cs b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/jwtExceptions/JwtSignatureValidationException.cs new file mode 100644 index 00000000..962b7cec --- /dev/null +++ b/cybersource-rest-auth-netstandard/AuthenticationSdk/AuthenticationSdk/util/jwtExceptions/JwtSignatureValidationException.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace AuthenticationSdk.util.jwtExceptions +{ + public class JwtSignatureValidationException : Exception + { + public JwtSignatureValidationException(string message) : base(message) { } + public JwtSignatureValidationException(string message, Exception cause) : base(message, cause) { } + } +} diff --git a/cybersource-rest-client-netstandard/cybersource-rest-client-netstandard/Utilities/CaptureContext/CaptureContextParsingUtility.cs b/cybersource-rest-client-netstandard/cybersource-rest-client-netstandard/Utilities/CaptureContext/CaptureContextParsingUtility.cs new file mode 100644 index 00000000..a67a0707 --- /dev/null +++ b/cybersource-rest-client-netstandard/cybersource-rest-client-netstandard/Utilities/CaptureContext/CaptureContextParsingUtility.cs @@ -0,0 +1,108 @@ +using System; +using Newtonsoft.Json.Linq; +using CyberSource.Client; +using CyberSource.Utilities.CaptureContext; +using System.Threading.Tasks; +using AuthenticationSdk.util; +using AuthenticationSdk.util.jwtExceptions; + +namespace CyberSource.Utilities.CaptureContext +{ + public static class CaptureContextParsingUtility + { + public static JObject parseCaptureContextResponse(string jwtToken, Configuration config, bool verifyJWT) + { + // Parse JWT Token for any malformations + + string payLoad = JWTUtility.Parse(jwtToken); + var jsonPayLoad = JObject.Parse(payLoad); + if (!verifyJWT) + { + return jsonPayLoad; + } + // Extract 'kid' from JWT header + + var header = JWTUtility.GetJwtHeaders(jwtToken); + var kid = header.ContainsKey("kid") ? header["kid"].ToString() : null; + if (string.IsNullOrEmpty(kid)) + { + throw new Exception("JWT token does not contain 'kid' in header"); + } + + var runEnvironment = config.MerchantConfigDictionaryObj.ContainsKey("runEnvironment") ? config.MerchantConfigDictionaryObj["runEnvironment"] : Constants.HostName; + + string publicKey = ""; + bool isPublicKeyFromCache = false; + bool isJWTVerified = false; + + try + { + publicKey = Cache.GetPublicKeyFromCache(runEnvironment, kid); + isPublicKeyFromCache = true; + } + catch (Exception) + { + publicKey = FetchPublicKeyFromApi(kid, runEnvironment).GetAwaiter().GetResult(); + } + + // After fetching publicKey (from cache or API), verify JWT signature + try + { + try + { + if (publicKey == null) + { + throw new Exception("Public key is null. No public key is available in the cache or could be retrieved from the API for the specified KID."); + } + + isJWTVerified = JWTUtility.VerifyJwt(jwtToken, publicKey); + } + catch (Exception) + { + if (isPublicKeyFromCache) + { + // Try to fetch fresh public key from API and re-verify + publicKey = FetchPublicKeyFromApi(kid, runEnvironment).GetAwaiter().GetResult(); + isJWTVerified = JWTUtility.VerifyJwt(jwtToken, publicKey); + } + } + + if (!isJWTVerified) + { + throw new JwtSignatureValidationException("JWT signature verification has failed"); + } + } + catch (JwtSignatureValidationException) + { + throw; + } + catch (InvalidJwkException) + { + throw; + } + catch (Exception) + { + throw; + } + + return jsonPayLoad; + } + + private static async Task FetchPublicKeyFromApi(string kid, string runEnvironment) + { + string publicKey = null; + try + { + publicKey = await PublicKeyApiController.FetchPublicKeyAsync(kid, runEnvironment); + } + catch (Exception ex) + { + Console.WriteLine(ex); + throw; + } + + Cache.AddPublicKeyToCache(publicKey, runEnvironment, kid); + return publicKey; + } + } +} \ No newline at end of file diff --git a/cybersource-rest-client-netstandard/cybersource-rest-client-netstandard/Utilities/CaptureContext/PublicKeyApiController.cs b/cybersource-rest-client-netstandard/cybersource-rest-client-netstandard/Utilities/CaptureContext/PublicKeyApiController.cs new file mode 100644 index 00000000..c5a4ad49 --- /dev/null +++ b/cybersource-rest-client-netstandard/cybersource-rest-client-netstandard/Utilities/CaptureContext/PublicKeyApiController.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading.Tasks; +using RestSharp; + +namespace CyberSource.Utilities.CaptureContext +{ + public static class PublicKeyApiController + { + /// + /// Fetches the public key JSON from the specified endpoint using RestSharp. + /// + /// The key ID. + /// The environment domain (e.g., "apitest.cybersource.com"). + /// JSON string of the public key. + public static async Task FetchPublicKeyAsync(string kid, string runEnvironment) + { + if (string.IsNullOrWhiteSpace(kid)) + { + throw new ArgumentException("Key ID (kid) must not be null or empty.", nameof(kid)); + } + + if (string.IsNullOrWhiteSpace(runEnvironment)) + { + throw new ArgumentException("Run environment must not be null or empty.", nameof(runEnvironment)); + } + + var url = $"https://{runEnvironment}/flex/v2/public-keys/{kid}"; + + var client = new RestClient(url); + var request = new RestRequest("", Method.Get); + + var response = await client.ExecuteAsync(request).ConfigureAwait(false); + + if (!response.IsSuccessful) + { + throw new InvalidOperationException($"Failed to fetch public key. Status: {response.StatusCode}, Error: {response.ErrorMessage}"); + } + + return response.Content; + } + } +}