From b53fc777098c3a0f89346960989d9de37939d0eb Mon Sep 17 00:00:00 2001 From: mahmishr Date: Thu, 13 Nov 2025 11:39:50 +0530 Subject: [PATCH 1/7] jwt utility code --- .../flex/CaptureContextParsingUtility.py | 188 +++++++++++ .../utilities/flex/PublicKeyApiController.py | 105 ++++++ authenticationsdk/util/Cache.py | 38 ++- .../util/GlobalLabelParameters.py | 1 + authenticationsdk/util/jwt/JWTExceptions.py | 104 ++++++ authenticationsdk/util/jwt/JWTUtility.py | 308 ++++++++++++++++++ authenticationsdk/util/jwt/__init__.py | 20 ++ 7 files changed, 763 insertions(+), 1 deletion(-) create mode 100644 CyberSource/utilities/flex/CaptureContextParsingUtility.py create mode 100644 CyberSource/utilities/flex/PublicKeyApiController.py create mode 100644 authenticationsdk/util/jwt/JWTExceptions.py create mode 100644 authenticationsdk/util/jwt/JWTUtility.py create mode 100644 authenticationsdk/util/jwt/__init__.py diff --git a/CyberSource/utilities/flex/CaptureContextParsingUtility.py b/CyberSource/utilities/flex/CaptureContextParsingUtility.py new file mode 100644 index 00000000..78e45ba8 --- /dev/null +++ b/CyberSource/utilities/flex/CaptureContextParsingUtility.py @@ -0,0 +1,188 @@ +# coding: utf-8 +""" +Capture Context Parsing Utility Module. + +This module provides functionality to parse and verify JWT capture context responses +from CyberSource. It supports signature verification using public keys fetched from +the Flex V2 API, with caching to improve performance. +""" + +from __future__ import absolute_import + +from authenticationsdk.util.jwt.JWTUtility import parse, verify_jwt +from authenticationsdk.util.jwt.JWTExceptions import ( + InvalidJwtException, + InvalidJwkException, + JwtSignatureValidationException +) +from authenticationsdk.util.Cache import FileCache +from CyberSource.utilities.flex.PublicKeyApiController import fetch_public_key + + +def parse_capture_context_response(jwt_value, merchant_config, verify_jwt_signature=True): + """ + Parse a capture context JWT response and optionally verify its signature. + + This function parses a JWT token and optionally verifies its signature using + the public key fetched from the CyberSource Flex V2 API. Public keys are cached + to improve performance on subsequent verifications. + + Args: + jwt_value (str): The JWT token to parse + merchant_config (object): The merchant configuration object containing run_environment + verify_jwt_signature (bool): Whether to verify the JWT signature (default: True) + + Returns: + dict: The decoded JWT payload + + Raises: + InvalidJwtException: If the JWT token is invalid or cannot be parsed + InvalidJwkException: If the public key (JWK) is invalid + JwtSignatureValidationException: If signature verification fails + ValueError: If merchant_config is missing or invalid + Exception: For other errors during parsing or verification + + Example: + >>> from authenticationsdk.core.MerchantConfiguration import MerchantConfiguration + >>> merchant_config = MerchantConfiguration() + >>> merchant_config.run_environment = "apitest.cybersource.com" + >>> payload = parse_capture_context_response(jwt_token, merchant_config, True) + >>> print(payload) + """ + if not jwt_value: + raise InvalidJwtException('JWT value is null or undefined') + + if not merchant_config: + raise ValueError('merchantConfig is required') + + # Parse the JWT token + try: + parsed_jwt = parse(jwt_value) + except InvalidJwtException: + raise + except Exception as parse_error: + raise InvalidJwtException('Failed to parse JWT token', parse_error) + + # If verification is not requested, return the payload immediately + if not verify_jwt_signature: + return parsed_jwt['payload'] + + # Extract the key ID from the JWT header + header = parsed_jwt['header'] + kid = header.get('kid') + + if not kid: + raise JwtSignatureValidationException('JWT header missing key ID (kid) field') + + # Get the run environment from merchant config + run_environment = None + if hasattr(merchant_config, 'run_environment'): + run_environment = merchant_config.run_environment + elif hasattr(merchant_config, 'getRunEnvironment'): + run_environment = merchant_config.getRunEnvironment() + + if not run_environment: + raise ValueError('Run environment not found in merchant config') + + # Try to get the public key from cache + cache = FileCache() + public_key = None + is_public_key_from_cache = False + + try: + public_key = cache.get_public_key_from_cache(run_environment, kid) + is_public_key_from_cache = True + except KeyError: + is_public_key_from_cache = False + + # If public key is not in cache or verification fails, fetch from API + if not is_public_key_from_cache: + public_key = _fetch_public_key_and_verify(jwt_value, parsed_jwt, kid, run_environment, cache) + return parsed_jwt['payload'] + + # Try to verify with cached public key + try: + verify_jwt(jwt_value, public_key) + return parsed_jwt['payload'] + except (JwtSignatureValidationException, InvalidJwkException): + # If verification fails with cached key, fetch fresh key from API + public_key = _fetch_public_key_and_verify(jwt_value, parsed_jwt, kid, run_environment, cache) + return parsed_jwt['payload'] + + +def _fetch_public_key_and_verify(jwt_value, parsed_jwt, kid, run_environment, cache): + """ + Fetch public key from API and perform JWT verification. + + This is a private helper function that fetches the public key from the + Flex V2 API, caches it, and verifies the JWT signature. + + Args: + jwt_value (str): The JWT token + parsed_jwt (dict): The parsed JWT object + kid (str): The key ID + run_environment (str): The runtime environment + cache (FileCache): The cache instance + + Returns: + dict: The public key in JWK format + + Raises: + InvalidJwkException: If the JWK cannot be parsed + JwtSignatureValidationException: If verification fails + Exception: For other errors during fetch or verification + """ + try: + public_key = _fetch_public_key_from_api(kid, run_environment, cache) + except Exception as fetch_error: + # Re-raise with appropriate error type + if 'Invalid Runtime URL' in str(fetch_error): + raise ValueError('Invalid Runtime URL in Merchant Config') + elif 'No response received' in str(fetch_error) or 'Network error' in str(fetch_error): + raise Exception('Error while trying to retrieve public key from server') + elif 'Failed to parse JWK' in str(fetch_error): + raise InvalidJwkException('JWK received from server cannot be parsed correctly', fetch_error) + else: + raise Exception('Error while trying to retrieve public key from server') + + # Verify the JWT with the fetched public key + try: + verify_jwt(jwt_value, public_key) + return public_key + except (JwtSignatureValidationException, InvalidJwkException): + raise JwtSignatureValidationException('JWT validation failed') + + +def _fetch_public_key_from_api(kid, run_environment, cache): + """ + Fetch public key from API and add it to cache. + + This is a private helper function that fetches the public key from the + Flex V2 API endpoint and stores it in the cache. + + Args: + kid (str): The key ID + run_environment (str): The runtime environment + cache (FileCache): The cache instance + + Returns: + dict: The public key in JWK format + + Raises: + Exception: If the fetch operation fails + """ + try: + public_key = fetch_public_key(kid, run_environment) + + # Add to cache + try: + cache.add_public_key_to_cache(run_environment, kid, public_key) + except Exception: + # Cache failure is not critical, continue with the public key + pass + + return public_key + + except Exception as error: + # Re-raise the error + raise error diff --git a/CyberSource/utilities/flex/PublicKeyApiController.py b/CyberSource/utilities/flex/PublicKeyApiController.py new file mode 100644 index 00000000..4f79c22d --- /dev/null +++ b/CyberSource/utilities/flex/PublicKeyApiController.py @@ -0,0 +1,105 @@ +# coding: utf-8 +""" +Public Key API Controller Module. + +This module provides functionality to fetch RSA public keys in JWK format +from the CyberSource Flex V2 public keys API endpoint. +""" + +from __future__ import absolute_import + +import requests +import json + +from authenticationsdk.util.jwt.JWTUtility import get_rsa_public_key_from_jwk +from authenticationsdk.util.jwt.JWTExceptions import InvalidJwkException + + +def fetch_public_key(kid, run_environment): + """ + Fetch the public key for the given key ID (kid) from the specified run environment. + + This function makes an HTTP GET request to the Flex V2 public keys endpoint + to retrieve the RSA public key in JWK format. + + Args: + kid (str): The key ID for which to fetch the public key + run_environment (str): The environment domain (e.g., 'apitest.cybersource.com') + + Returns: + dict: The RSA public key in JWK format + + Raises: + ValueError: If kid or run_environment is missing + requests.exceptions.RequestException: If the HTTP request fails + InvalidJwkException: If the JWK cannot be parsed correctly + Exception: For other errors during the fetch or parse process + """ + if not kid: + raise ValueError('kid parameter is required') + + if not run_environment: + raise ValueError('runEnvironment parameter is required') + + url = f"https://{run_environment}/flex/v2/public-keys/{kid}" + + headers = { + 'Accept': 'application/json' + } + + try: + response = requests.get(url, headers=headers) + + # Check if the request was successful + response.raise_for_status() + + if not response.text: + raise Exception('Empty response received from public key endpoint') + + # Parse the response + try: + response_data = response.json() + jwk_json_string = json.dumps(response_data) + except json.JSONDecodeError: + # If it's already a string, use it directly + jwk_json_string = response.text + + # Extract and validate the RSA public key from JWK + try: + public_key = get_rsa_public_key_from_jwk(jwk_json_string) + if not public_key: + raise Exception('Invalid public key received from JWK') + return public_key + except InvalidJwkException as parse_error: + error = Exception(f'Failed to parse JWK response: {str(parse_error)}') + error.original_error = parse_error + raise error + + except requests.exceptions.HTTPError as http_error: + status_code = http_error.response.status_code if http_error.response else 'Unknown' + status_text = http_error.response.reason if http_error.response else 'Unknown' + error = Exception( + f'HTTP {status_code}: {status_text} - Failed to fetch public key for kid: {kid}' + ) + error.status = status_code + error.original_error = http_error + raise error + + except requests.exceptions.ConnectionError as conn_error: + error = Exception(f'No response received - Failed to fetch public key for kid: {kid}') + error.original_error = conn_error + raise error + + except requests.exceptions.RequestException as req_error: + error = Exception(f'Request error: {str(req_error)}') + error.original_error = req_error + raise error + + except Exception as general_error: + # Re-raise if it's already one of our custom errors + if hasattr(general_error, 'original_error'): + raise + # Otherwise wrap it + error = Exception(f'Error fetching public key: {str(general_error)}') + error.original_error = general_error + raise error diff --git a/authenticationsdk/util/Cache.py b/authenticationsdk/util/Cache.py index 3320290b..d9943731 100644 --- a/authenticationsdk/util/Cache.py +++ b/authenticationsdk/util/Cache.py @@ -39,6 +39,8 @@ def _initialize_cache(self): self._p12_cache_lock = threading.RLock() self.mlecache = {} self._mle_cache_lock = threading.RLock() + self.public_key_cache = {} + self._public_key_cache_lock = threading.RLock() def fetch_cached_p12_certificate(self, merchant_config, p12_file_path, key_password): try: @@ -166,4 +168,38 @@ def setup_mle_cache(self, merchant_config, cache_key, certificate_file_path): private_key ) with self._mle_cache_lock: - self.mlecache[cache_key] = cert_info \ No newline at end of file + self.mlecache[cache_key] = cert_info + + def add_public_key_to_cache(self, run_environment, key_id, public_key): + """ + Add a public key to the cache for Flex V2 public keys. + + Args: + run_environment (str): The runtime environment (e.g., 'apitest.cybersource.com') + key_id (str): The key ID (kid) from the JWT header + public_key (dict): The RSA public key in JWK format + """ + cache_key = f"{GlobalLabelParameters.PUBLIC_KEY_CACHE_IDENTIFIER}_{run_environment}_{key_id}" + with self._public_key_cache_lock: + self.public_key_cache[cache_key] = public_key + + def get_public_key_from_cache(self, run_environment, key_id): + """ + Retrieve a public key from the cache. + + Args: + run_environment (str): The runtime environment (e.g., 'apitest.cybersource.com') + key_id (str): The key ID (kid) from the JWT header + + Returns: + dict: The RSA public key in JWK format + + Raises: + KeyError: If the public key is not found in the cache + """ + cache_key = f"{GlobalLabelParameters.PUBLIC_KEY_CACHE_IDENTIFIER}_{run_environment}_{key_id}" + + with self._public_key_cache_lock: + if cache_key not in self.public_key_cache: + raise KeyError(f"Public key not found in cache for [{run_environment}, {key_id}]") + return self.public_key_cache[cache_key] \ No newline at end of file diff --git a/authenticationsdk/util/GlobalLabelParameters.py b/authenticationsdk/util/GlobalLabelParameters.py index df1f311e..d6bcbfa8 100644 --- a/authenticationsdk/util/GlobalLabelParameters.py +++ b/authenticationsdk/util/GlobalLabelParameters.py @@ -99,6 +99,7 @@ class GlobalLabelParameters: MESSAGE_AFTER_MLE_REQUEST = "Request after MLE: " MLE_CACHE_IDENTIFIER_FOR_CONFIG_CERT = "mleCertFromMerchantConfig" MLE_CACHE_IDENTIFIER_FOR_P12_CERT = "mleCertFromP12" + PUBLIC_KEY_CACHE_IDENTIFIER = "FlexV2PublicKeys" DEFAULT_MAX_IDLE_CONNECTIONS = 100 DEFAULT_MAX_POOL_SIZE = 10 DEFAULT_MAX_KEEP_ALIVE_DELAY = 300 # in seconds diff --git a/authenticationsdk/util/jwt/JWTExceptions.py b/authenticationsdk/util/jwt/JWTExceptions.py new file mode 100644 index 00000000..5bf947ee --- /dev/null +++ b/authenticationsdk/util/jwt/JWTExceptions.py @@ -0,0 +1,104 @@ +# coding: utf-8 +""" +Custom exception classes for JWT operations. + +This module provides specialized exception classes for handling JWT-related errors, +including invalid JWT tokens, invalid JWK (JSON Web Key) formats, and signature +validation failures. +""" + + +class InvalidJwkException(Exception): + """ + Exception raised when a JWK (JSON Web Key) is invalid or malformed. + + This exception is used for errors related to: + - Invalid JWK JSON format + - Missing required JWK parameters (e.g., n, e for RSA keys) + - Incorrect key type (kty) + - Base64url decoding errors in JWK parameters + + Args: + message (str): Error message describing the invalid JWK + cause (Exception, optional): The underlying exception that caused this error + """ + + def __init__(self, message, cause=None): + """ + Initialize the InvalidJwkException. + + Args: + message (str): Error message describing the invalid JWK + cause (Exception, optional): The underlying exception that caused this error + """ + super(InvalidJwkException, self).__init__(message) + self.cause = cause + self.message = message + + # Chain the exception if cause is provided + if cause: + self.__cause__ = cause + + +class InvalidJwtException(Exception): + """ + Exception raised when a JWT token is invalid or malformed. + + This exception is used for errors related to: + - Invalid JWT token format (not 3 parts separated by dots) + - Empty or null JWT tokens + - Base64url decoding errors + - Invalid JSON in header or payload + + Args: + message (str): Error message describing the invalid JWT token + cause (Exception, optional): The underlying exception that caused this error + """ + + def __init__(self, message, cause=None): + """ + Initialize the InvalidJwtException. + + Args: + message (str): Error message describing the invalid JWT token + cause (Exception, optional): The underlying exception that caused this error + """ + super(InvalidJwtException, self).__init__(message) + self.cause = cause + self.message = message + + # Chain the exception if cause is provided + if cause: + self.__cause__ = cause + + +class JwtSignatureValidationException(Exception): + """ + Exception raised when JWT signature validation fails. + + This exception is used for errors related to: + - Signature verification failures + - Missing or unsupported signing algorithms + - Missing public key + - Cryptographic verification errors + + Args: + message (str): Error message describing the signature validation failure + cause (Exception, optional): The underlying exception that caused this error + """ + + def __init__(self, message, cause=None): + """ + Initialize the JwtSignatureValidationException. + + Args: + message (str): Error message describing the signature validation failure + cause (Exception, optional): The underlying exception that caused this error + """ + super(JwtSignatureValidationException, self).__init__(message) + self.cause = cause + self.message = message + + # Chain the exception if cause is provided + if cause: + self.__cause__ = cause diff --git a/authenticationsdk/util/jwt/JWTUtility.py b/authenticationsdk/util/jwt/JWTUtility.py new file mode 100644 index 00000000..8ff860ac --- /dev/null +++ b/authenticationsdk/util/jwt/JWTUtility.py @@ -0,0 +1,308 @@ +# coding: utf-8 +""" +JWT (JSON Web Token) Utility Module. + +This module provides utilities for parsing and verifying JWT tokens, including: +- Parsing JWT tokens into header, payload, and signature components +- Verifying JWT signatures using RSA public keys in JWK format +- Converting JWK (JSON Web Key) to PEM format for cryptographic operations + +Supports JWT algorithms: RS256, RS384, RS512 +""" + +from __future__ import absolute_import + +import base64 +import json +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa, padding +from cryptography.hazmat.backends import default_backend + +from .JWTExceptions import InvalidJwkException, InvalidJwtException, JwtSignatureValidationException + + +# Supported JWT algorithms and their corresponding hash algorithms +SUPPORTED_ALGORITHMS = { + 'RS256': hashes.SHA256(), + 'RS384': hashes.SHA384(), + 'RS512': hashes.SHA512() +} + +# Error messages constants +ERROR_MESSAGES = { + 'UNSUPPORTED_ALGORITHM': 'Unsupported JWT algorithm: {}. Supported algorithms: {}', + 'MISSING_ALGORITHM': 'JWT header missing algorithm (alg) field', + 'NO_PUBLIC_KEY': 'No public key found', + 'INVALID_PUBLIC_KEY_FORMAT': 'Invalid public key format. Expected JWK object or JSON string.', + 'INVALID_RSA_KEY': 'Public key must be an RSA key (kty: RSA)', + 'MISSING_RSA_PARAMS': 'Invalid RSA JWK: missing required parameters (n, e)' +} + + +def _base64url_decode(input_str): + """ + Decode a base64url encoded string. + + Base64url encoding is similar to base64 but uses URL-safe characters + and removes padding. + + Args: + input_str (str): The base64url encoded string + + Returns: + bytes: The decoded bytes + + Raises: + Exception: If decoding fails + """ + # Add padding if needed + padding = 4 - (len(input_str) % 4) + if padding and padding != 4: + input_str += '=' * padding + + # Replace URL-safe characters with standard base64 characters + input_str = input_str.replace('-', '+').replace('_', '/') + + return base64.b64decode(input_str) + + +def _decode_jwt_part(base64url_string, part_name): + """ + Decode a base64url encoded string to a JSON object. + + Args: + base64url_string (str): The base64url encoded string + part_name (str): Name of the JWT part for error reporting (e.g., 'header', 'payload') + + Returns: + dict: The decoded JSON object + + Raises: + InvalidJwtException: If decoding or parsing fails + """ + try: + decoded_bytes = _base64url_decode(base64url_string) + json_string = decoded_bytes.decode('utf-8') + return json.loads(json_string) + except json.JSONDecodeError as decode_err: + raise InvalidJwtException('Invalid JSON in JWT {}'.format(part_name), decode_err) + except Exception as decode_err: + raise InvalidJwtException('Failed to decode JWT {} from base64url'.format(part_name), decode_err) + + +def _validate_and_parse_jwk(public_key): + """ + Validate and parse a JWK public key. + + Args: + public_key (dict or str): The RSA public key (JWK object or JSON string) + + Returns: + dict: The validated JWK object + + Raises: + InvalidJwkException: If the public key is invalid + """ + jwk_key = None + + if isinstance(public_key, str): + try: + jwk_key = json.loads(public_key) + except json.JSONDecodeError as parse_err: + raise InvalidJwkException('Invalid public key JSON format', parse_err) + elif isinstance(public_key, dict) and 'kty' in public_key: + jwk_key = public_key + else: + raise InvalidJwkException(ERROR_MESSAGES['INVALID_PUBLIC_KEY_FORMAT']) + + if jwk_key.get('kty') != 'RSA': + raise InvalidJwkException(ERROR_MESSAGES['INVALID_RSA_KEY']) + + if not jwk_key.get('n') or not jwk_key.get('e'): + raise InvalidJwkException(ERROR_MESSAGES['MISSING_RSA_PARAMS']) + + return jwk_key + + +def _convert_jwk_to_public_key(jwk_key): + """ + Convert JWK RSA parameters to a cryptography RSA public key object. + + Args: + jwk_key (dict): The JWK object with RSA parameters + + Returns: + RSAPublicKey: The cryptography RSA public key object + + Raises: + InvalidJwkException: If key conversion fails + """ + try: + # Decode base64url encoded n and e parameters + n_bytes = _base64url_decode(jwk_key['n']) + e_bytes = _base64url_decode(jwk_key['e']) + except Exception as decode_err: + raise InvalidJwkException('Invalid base64url encoding in JWK parameters', decode_err) + + try: + # Convert bytes to integers + n = int.from_bytes(n_bytes, byteorder='big') + e = int.from_bytes(e_bytes, byteorder='big') + + # Create RSA public key + public_numbers = rsa.RSAPublicNumbers(e, n) + public_key = public_numbers.public_key(default_backend()) + + return public_key + except Exception as key_err: + raise InvalidJwkException('Failed to create RSA public key from JWK', key_err) + + +def parse(jwt_token): + """ + Parse a JWT token and extract its header, payload, and signature components. + + Args: + jwt_token (str): The JWT token to parse + + Returns: + dict: Dictionary containing: + - header (dict): Decoded JWT header + - payload (dict): Decoded JWT payload + - signature (str): Base64url encoded signature + - raw_header (str): Raw base64url encoded header + - raw_payload (str): Raw base64url encoded payload + + Raises: + InvalidJwtException: If the JWT token is invalid or malformed + """ + if jwt_token is None: + raise InvalidJwtException('JWT token is null or undefined') + + if not isinstance(jwt_token, str): + raise InvalidJwtException('JWT token must be a string') + + token_parts = jwt_token.split('.') + if len(token_parts) != 3: + raise InvalidJwtException('Invalid JWT token format: expected 3 parts separated by dots') + + # Validate that all parts are non-empty + if not token_parts[0] or not token_parts[1] or not token_parts[2]: + raise InvalidJwtException('Invalid JWT token: one or more parts are empty') + + try: + # Decode header and payload + header = _decode_jwt_part(token_parts[0], 'header') + payload = _decode_jwt_part(token_parts[1], 'payload') + signature = token_parts[2] + + return { + 'header': header, + 'payload': payload, + 'signature': signature, + 'raw_header': token_parts[0], + 'raw_payload': token_parts[1] + } + except InvalidJwtException: + # Re-raise our custom exceptions + raise + except Exception as err: + raise InvalidJwtException('Malformed JWT cannot be parsed', err) + + +def verify_jwt(jwt_token, public_key): + """ + Verify a JWT token using an RSA public key. + + This function validates the JWT signature using the provided RSA public key + in JWK format. It supports RS256, RS384, and RS512 signing algorithms. + + Args: + jwt_token (str): The JWT token to verify + public_key (dict or str): The RSA public key (JWK object or JSON string) + + Raises: + InvalidJwtException: If JWT parsing fails + InvalidJwkException: If the public key is invalid + JwtSignatureValidationException: If signature verification fails or + if the algorithm is unsupported + """ + if not public_key: + raise JwtSignatureValidationException(ERROR_MESSAGES['NO_PUBLIC_KEY']) + + if not jwt_token: + raise JwtSignatureValidationException('JWT token is null or undefined') + + # Parse the JWT token + parsed_jwt = parse(jwt_token) + + header = parsed_jwt['header'] + signature = parsed_jwt['signature'] + raw_header = parsed_jwt['raw_header'] + raw_payload = parsed_jwt['raw_payload'] + + # Get the algorithm from header + algorithm = header.get('alg') + if not algorithm: + raise JwtSignatureValidationException(ERROR_MESSAGES['MISSING_ALGORITHM']) + + # Check if algorithm is supported + hash_algorithm = SUPPORTED_ALGORITHMS.get(algorithm) + if not hash_algorithm: + supported = ', '.join(SUPPORTED_ALGORITHMS.keys()) + raise JwtSignatureValidationException( + ERROR_MESSAGES['UNSUPPORTED_ALGORITHM'].format(algorithm, supported) + ) + + # Validate and parse the JWK public key + jwk_key = _validate_and_parse_jwk(public_key) + + # Convert JWK to cryptography public key object + rsa_public_key = _convert_jwk_to_public_key(jwk_key) + + # Prepare signing input (header.payload) + signing_input = '{}.{}'.format(raw_header, raw_payload) + signing_input_bytes = signing_input.encode('utf-8') + + # Decode the signature from base64url + try: + signature_bytes = _base64url_decode(signature) + except Exception as sig_decode_err: + raise JwtSignatureValidationException( + 'Invalid base64url encoding in JWT signature', sig_decode_err + ) + + # Verify the signature + try: + rsa_public_key.verify( + signature_bytes, + signing_input_bytes, + padding.PKCS1v15(), + hash_algorithm + ) + except Exception as verify_err: + raise JwtSignatureValidationException('JWT signature verification failed', verify_err) + + +def get_rsa_public_key_from_jwk(jwk_json_string): + """ + Extract an RSA public key from a JWK JSON string. + + Args: + jwk_json_string (str): The JWK JSON string containing the RSA key + + Returns: + dict: The RSA public key object (JWK) + + Raises: + InvalidJwkException: If the JWK is invalid or not an RSA key + """ + try: + jwk_data = json.loads(jwk_json_string) + if jwk_data.get('kty') != 'RSA': + raise InvalidJwkException('JWK Algorithm mismatch. Expected algorithm : RSA') + return jwk_data + except InvalidJwkException: + raise + except Exception as err: + raise InvalidJwkException('Failed to parse JWK or extract RSA public key', err) diff --git a/authenticationsdk/util/jwt/__init__.py b/authenticationsdk/util/jwt/__init__.py new file mode 100644 index 00000000..98413d7c --- /dev/null +++ b/authenticationsdk/util/jwt/__init__.py @@ -0,0 +1,20 @@ +# coding: utf-8 +""" +JWT (JSON Web Token) utilities package. + +This package provides utilities for JWT parsing, verification, and exception handling. +""" + +from __future__ import absolute_import + +from .JWTUtility import parse, verify_jwt, get_rsa_public_key_from_jwk +from .JWTExceptions import InvalidJwtException, InvalidJwkException, JwtSignatureValidationException + +__all__ = [ + 'parse', + 'verify_jwt', + 'get_rsa_public_key_from_jwk', + 'InvalidJwtException', + 'InvalidJwkException', + 'JwtSignatureValidationException' +] From 002269218af25ee0781c20c937e8de854e8acc7d Mon Sep 17 00:00:00 2001 From: mahmishr Date: Tue, 18 Nov 2025 13:02:16 +0530 Subject: [PATCH 2/7] comments resolved --- .../flex/CaptureContextParsingUtility.py | 53 +++++-------------- 1 file changed, 13 insertions(+), 40 deletions(-) diff --git a/CyberSource/utilities/flex/CaptureContextParsingUtility.py b/CyberSource/utilities/flex/CaptureContextParsingUtility.py index 78e45ba8..749d4968 100644 --- a/CyberSource/utilities/flex/CaptureContextParsingUtility.py +++ b/CyberSource/utilities/flex/CaptureContextParsingUtility.py @@ -97,7 +97,7 @@ def parse_capture_context_response(jwt_value, merchant_config, verify_jwt_signat # If public key is not in cache or verification fails, fetch from API if not is_public_key_from_cache: - public_key = _fetch_public_key_and_verify(jwt_value, parsed_jwt, kid, run_environment, cache) + public_key = _fetch_public_key_and_verify(jwt_value, kid, run_environment, cache) return parsed_jwt['payload'] # Try to verify with cached public key @@ -106,11 +106,11 @@ def parse_capture_context_response(jwt_value, merchant_config, verify_jwt_signat return parsed_jwt['payload'] except (JwtSignatureValidationException, InvalidJwkException): # If verification fails with cached key, fetch fresh key from API - public_key = _fetch_public_key_and_verify(jwt_value, parsed_jwt, kid, run_environment, cache) + public_key = _fetch_public_key_and_verify(jwt_value, kid, run_environment, cache) return parsed_jwt['payload'] -def _fetch_public_key_and_verify(jwt_value, parsed_jwt, kid, run_environment, cache): +def _fetch_public_key_and_verify(jwt_value, kid, run_environment, cache): """ Fetch public key from API and perform JWT verification. @@ -119,7 +119,6 @@ def _fetch_public_key_and_verify(jwt_value, parsed_jwt, kid, run_environment, ca Args: jwt_value (str): The JWT token - parsed_jwt (dict): The parsed JWT object kid (str): The key ID run_environment (str): The runtime environment cache (FileCache): The cache instance @@ -133,7 +132,16 @@ def _fetch_public_key_and_verify(jwt_value, parsed_jwt, kid, run_environment, ca Exception: For other errors during fetch or verification """ try: - public_key = _fetch_public_key_from_api(kid, run_environment, cache) + # Fetch the public key from the Flex V2 API + public_key = fetch_public_key(kid, run_environment) + + # Add to cache (cache failure is not critical) + try: + cache.add_public_key_to_cache(run_environment, kid, public_key) + except Exception: + # Cache failure is not critical, continue with the public key + pass + except Exception as fetch_error: # Re-raise with appropriate error type if 'Invalid Runtime URL' in str(fetch_error): @@ -151,38 +159,3 @@ def _fetch_public_key_and_verify(jwt_value, parsed_jwt, kid, run_environment, ca return public_key except (JwtSignatureValidationException, InvalidJwkException): raise JwtSignatureValidationException('JWT validation failed') - - -def _fetch_public_key_from_api(kid, run_environment, cache): - """ - Fetch public key from API and add it to cache. - - This is a private helper function that fetches the public key from the - Flex V2 API endpoint and stores it in the cache. - - Args: - kid (str): The key ID - run_environment (str): The runtime environment - cache (FileCache): The cache instance - - Returns: - dict: The public key in JWK format - - Raises: - Exception: If the fetch operation fails - """ - try: - public_key = fetch_public_key(kid, run_environment) - - # Add to cache - try: - cache.add_public_key_to_cache(run_environment, kid, public_key) - except Exception: - # Cache failure is not critical, continue with the public key - pass - - return public_key - - except Exception as error: - # Re-raise the error - raise error From 747aed62f5e4b2d615554d74ac2d4afd675c4355 Mon Sep 17 00:00:00 2001 From: mahmishr Date: Tue, 18 Nov 2025 13:22:15 +0530 Subject: [PATCH 3/7] refactoring --- .../utilities/flex/CaptureContextParsingUtility.py | 7 ------- authenticationsdk/util/jwt/JWTUtility.py | 10 +++------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/CyberSource/utilities/flex/CaptureContextParsingUtility.py b/CyberSource/utilities/flex/CaptureContextParsingUtility.py index 749d4968..079ef0b9 100644 --- a/CyberSource/utilities/flex/CaptureContextParsingUtility.py +++ b/CyberSource/utilities/flex/CaptureContextParsingUtility.py @@ -41,13 +41,6 @@ def parse_capture_context_response(jwt_value, merchant_config, verify_jwt_signat JwtSignatureValidationException: If signature verification fails ValueError: If merchant_config is missing or invalid Exception: For other errors during parsing or verification - - Example: - >>> from authenticationsdk.core.MerchantConfiguration import MerchantConfiguration - >>> merchant_config = MerchantConfiguration() - >>> merchant_config.run_environment = "apitest.cybersource.com" - >>> payload = parse_capture_context_response(jwt_token, merchant_config, True) - >>> print(payload) """ if not jwt_value: raise InvalidJwtException('JWT value is null or undefined') diff --git a/authenticationsdk/util/jwt/JWTUtility.py b/authenticationsdk/util/jwt/JWTUtility.py index 8ff860ac..a27fa5ac 100644 --- a/authenticationsdk/util/jwt/JWTUtility.py +++ b/authenticationsdk/util/jwt/JWTUtility.py @@ -55,13 +55,9 @@ def _base64url_decode(input_str): Raises: Exception: If decoding fails """ - # Add padding if needed - padding = 4 - (len(input_str) % 4) - if padding and padding != 4: - input_str += '=' * padding - - # Replace URL-safe characters with standard base64 characters - input_str = input_str.replace('-', '+').replace('_', '/') + # Add padding and replace URL-safe characters with standard base64 characters + # Adding '==' ensures proper padding (base64 decoder ignores excess padding) + input_str = (input_str + '==').replace('-', '+').replace('_', '/') return base64.b64decode(input_str) From 38c483a71cdb001c50fd33fffd6bd0c8bf6708d0 Mon Sep 17 00:00:00 2001 From: mahmishr Date: Wed, 19 Nov 2025 15:46:34 +0530 Subject: [PATCH 4/7] comments resolved --- .../utilities/flex/PublicKeyApiController.py | 54 ++++++++++--------- authenticationsdk/util/jwt/JWTUtility.py | 24 +++------ 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/CyberSource/utilities/flex/PublicKeyApiController.py b/CyberSource/utilities/flex/PublicKeyApiController.py index 4f79c22d..b78404c4 100644 --- a/CyberSource/utilities/flex/PublicKeyApiController.py +++ b/CyberSource/utilities/flex/PublicKeyApiController.py @@ -8,8 +8,9 @@ from __future__ import absolute_import -import requests import json +import urllib3 +import certifi from authenticationsdk.util.jwt.JWTUtility import get_rsa_public_key_from_jwk from authenticationsdk.util.jwt.JWTExceptions import InvalidJwkException @@ -31,7 +32,7 @@ def fetch_public_key(kid, run_environment): Raises: ValueError: If kid or run_environment is missing - requests.exceptions.RequestException: If the HTTP request fails + urllib3.exceptions.HTTPError: If the HTTP request fails InvalidJwkException: If the JWK cannot be parsed correctly Exception: For other errors during the fetch or parse process """ @@ -47,22 +48,32 @@ def fetch_public_key(kid, run_environment): 'Accept': 'application/json' } + # Create urllib3 PoolManager with SSL verification + http = urllib3.PoolManager( + cert_reqs='CERT_REQUIRED', + ca_certs=certifi.where() + ) + try: - response = requests.get(url, headers=headers) + # Make the HTTP GET request + response = http.request('GET', url, headers=headers) # Check if the request was successful - response.raise_for_status() + if response.status not in range(200, 300): + error = Exception( + f'HTTP {response.status}: {response.reason} - Failed to fetch public key for kid: {kid}' + ) + error.status = response.status + raise error - if not response.text: + # Get response data as string + response_data = response.data.decode('utf-8') + + if not response_data: raise Exception('Empty response received from public key endpoint') - # Parse the response - try: - response_data = response.json() - jwk_json_string = json.dumps(response_data) - except json.JSONDecodeError: - # If it's already a string, use it directly - jwk_json_string = response.text + # Use response data directly as JSON string + jwk_json_string = response_data # Extract and validate the RSA public key from JWK try: @@ -75,24 +86,19 @@ def fetch_public_key(kid, run_environment): error.original_error = parse_error raise error - except requests.exceptions.HTTPError as http_error: - status_code = http_error.response.status_code if http_error.response else 'Unknown' - status_text = http_error.response.reason if http_error.response else 'Unknown' - error = Exception( - f'HTTP {status_code}: {status_text} - Failed to fetch public key for kid: {kid}' - ) - error.status = status_code + except urllib3.exceptions.HTTPError as http_error: + error = Exception(f'HTTP error - Failed to fetch public key for kid: {kid}') error.original_error = http_error raise error - except requests.exceptions.ConnectionError as conn_error: + except urllib3.exceptions.MaxRetryError as retry_error: error = Exception(f'No response received - Failed to fetch public key for kid: {kid}') - error.original_error = conn_error + error.original_error = retry_error raise error - except requests.exceptions.RequestException as req_error: - error = Exception(f'Request error: {str(req_error)}') - error.original_error = req_error + except urllib3.exceptions.SSLError as ssl_error: + error = Exception(f'SSL error - Failed to fetch public key for kid: {kid}') + error.original_error = ssl_error raise error except Exception as general_error: diff --git a/authenticationsdk/util/jwt/JWTUtility.py b/authenticationsdk/util/jwt/JWTUtility.py index a27fa5ac..8db1cdf1 100644 --- a/authenticationsdk/util/jwt/JWTUtility.py +++ b/authenticationsdk/util/jwt/JWTUtility.py @@ -28,16 +28,6 @@ 'RS512': hashes.SHA512() } -# Error messages constants -ERROR_MESSAGES = { - 'UNSUPPORTED_ALGORITHM': 'Unsupported JWT algorithm: {}. Supported algorithms: {}', - 'MISSING_ALGORITHM': 'JWT header missing algorithm (alg) field', - 'NO_PUBLIC_KEY': 'No public key found', - 'INVALID_PUBLIC_KEY_FORMAT': 'Invalid public key format. Expected JWK object or JSON string.', - 'INVALID_RSA_KEY': 'Public key must be an RSA key (kty: RSA)', - 'MISSING_RSA_PARAMS': 'Invalid RSA JWK: missing required parameters (n, e)' -} - def _base64url_decode(input_str): """ @@ -109,13 +99,13 @@ def _validate_and_parse_jwk(public_key): elif isinstance(public_key, dict) and 'kty' in public_key: jwk_key = public_key else: - raise InvalidJwkException(ERROR_MESSAGES['INVALID_PUBLIC_KEY_FORMAT']) + raise InvalidJwkException('Invalid public key format. Expected JWK object or JSON string.') if jwk_key.get('kty') != 'RSA': - raise InvalidJwkException(ERROR_MESSAGES['INVALID_RSA_KEY']) + raise InvalidJwkException('Public key must be an RSA key (kty: RSA)') if not jwk_key.get('n') or not jwk_key.get('e'): - raise InvalidJwkException(ERROR_MESSAGES['MISSING_RSA_PARAMS']) + raise InvalidJwkException('Invalid RSA JWK: missing required parameters (n, e)') return jwk_key @@ -184,7 +174,7 @@ def parse(jwt_token): # Validate that all parts are non-empty if not token_parts[0] or not token_parts[1] or not token_parts[2]: - raise InvalidJwtException('Invalid JWT token: one or more parts are empty') + raise InvalidJwtException('Malformed JWT : JWT provided does not conform to the proper structure for JWT') try: # Decode header and payload @@ -224,7 +214,7 @@ def verify_jwt(jwt_token, public_key): if the algorithm is unsupported """ if not public_key: - raise JwtSignatureValidationException(ERROR_MESSAGES['NO_PUBLIC_KEY']) + raise JwtSignatureValidationException('No public key provided') if not jwt_token: raise JwtSignatureValidationException('JWT token is null or undefined') @@ -240,14 +230,14 @@ def verify_jwt(jwt_token, public_key): # Get the algorithm from header algorithm = header.get('alg') if not algorithm: - raise JwtSignatureValidationException(ERROR_MESSAGES['MISSING_ALGORITHM']) + raise JwtSignatureValidationException('JWT header missing algorithm (alg) field') # Check if algorithm is supported hash_algorithm = SUPPORTED_ALGORITHMS.get(algorithm) if not hash_algorithm: supported = ', '.join(SUPPORTED_ALGORITHMS.keys()) raise JwtSignatureValidationException( - ERROR_MESSAGES['UNSUPPORTED_ALGORITHM'].format(algorithm, supported) + 'Unsupported JWT algorithm: {}. Supported algorithms: {}'.format(algorithm, supported) ) # Validate and parse the JWK public key From a660fde4becb430afaa89ff3e7f6b8f7dd6a6f9a Mon Sep 17 00:00:00 2001 From: mahmishr Date: Wed, 19 Nov 2025 15:50:26 +0530 Subject: [PATCH 5/7] matching existing way of sending api calls --- .../utilities/flex/PublicKeyApiController.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/CyberSource/utilities/flex/PublicKeyApiController.py b/CyberSource/utilities/flex/PublicKeyApiController.py index b78404c4..d06c1de9 100644 --- a/CyberSource/utilities/flex/PublicKeyApiController.py +++ b/CyberSource/utilities/flex/PublicKeyApiController.py @@ -10,10 +10,11 @@ import json import urllib3 -import certifi +import ssl from authenticationsdk.util.jwt.JWTUtility import get_rsa_public_key_from_jwk from authenticationsdk.util.jwt.JWTExceptions import InvalidJwkException +from CyberSource.configuration import Configuration def fetch_public_key(kid, run_environment): @@ -48,10 +49,19 @@ def fetch_public_key(kid, run_environment): 'Accept': 'application/json' } - # Create urllib3 PoolManager with SSL verification + # Use the same SSL configuration pattern as rest.py + configuration = Configuration() + if configuration.ssl_ca_cert: + cert_reqs = ssl.CERT_REQUIRED + ca_certs = configuration.ssl_ca_cert + else: + cert_reqs = ssl.CERT_NONE + ca_certs = None + + # Create urllib3 PoolManager with SSL configuration http = urllib3.PoolManager( - cert_reqs='CERT_REQUIRED', - ca_certs=certifi.where() + cert_reqs=cert_reqs, + ca_certs=ca_certs ) try: From 1f2009bfca5f28972e67e889d1003cafe6b904ba Mon Sep 17 00:00:00 2001 From: mahmishr Date: Wed, 19 Nov 2025 16:11:57 +0530 Subject: [PATCH 6/7] refactoring --- CyberSource/utilities/flex/PublicKeyApiController.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CyberSource/utilities/flex/PublicKeyApiController.py b/CyberSource/utilities/flex/PublicKeyApiController.py index d06c1de9..dd89f820 100644 --- a/CyberSource/utilities/flex/PublicKeyApiController.py +++ b/CyberSource/utilities/flex/PublicKeyApiController.py @@ -9,8 +9,9 @@ from __future__ import absolute_import import json -import urllib3 import ssl +import certifi +import urllib3 from authenticationsdk.util.jwt.JWTUtility import get_rsa_public_key_from_jwk from authenticationsdk.util.jwt.JWTExceptions import InvalidJwkException @@ -29,7 +30,7 @@ def fetch_public_key(kid, run_environment): run_environment (str): The environment domain (e.g., 'apitest.cybersource.com') Returns: - dict: The RSA public key in JWK format + RSAPublicKey: The RSA public key object from cryptography library Raises: ValueError: If kid or run_environment is missing @@ -55,10 +56,11 @@ def fetch_public_key(kid, run_environment): cert_reqs = ssl.CERT_REQUIRED ca_certs = configuration.ssl_ca_cert else: - cert_reqs = ssl.CERT_NONE - ca_certs = None + # Use certifi bundle as fallback (same as rest.py) + cert_reqs = ssl.CERT_REQUIRED # ← Changed from CERT_NONE + ca_certs = certifi.where() # ← Use certifi instead of None - # Create urllib3 PoolManager with SSL configuration + # Create urllib3 PoolManager with SSL configuration (matches rest.py pattern) http = urllib3.PoolManager( cert_reqs=cert_reqs, ca_certs=ca_certs From 2a98628b14bebd93f02b765283f6deac550a36cf Mon Sep 17 00:00:00 2001 From: mahmishr Date: Wed, 19 Nov 2025 17:15:28 +0530 Subject: [PATCH 7/7] comments resolved --- .../utilities/flex/CaptureContextParsingUtility.py | 4 ++-- .../utilities/flex/PublicKeyApiController.py | 14 +++++--------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/CyberSource/utilities/flex/CaptureContextParsingUtility.py b/CyberSource/utilities/flex/CaptureContextParsingUtility.py index 079ef0b9..69280761 100644 --- a/CyberSource/utilities/flex/CaptureContextParsingUtility.py +++ b/CyberSource/utilities/flex/CaptureContextParsingUtility.py @@ -140,11 +140,11 @@ def _fetch_public_key_and_verify(jwt_value, kid, run_environment, cache): if 'Invalid Runtime URL' in str(fetch_error): raise ValueError('Invalid Runtime URL in Merchant Config') elif 'No response received' in str(fetch_error) or 'Network error' in str(fetch_error): - raise Exception('Error while trying to retrieve public key from server') + raise Exception('Network Error: No response received while trying to retrieve public key from server', fetch_error) elif 'Failed to parse JWK' in str(fetch_error): raise InvalidJwkException('JWK received from server cannot be parsed correctly', fetch_error) else: - raise Exception('Error while trying to retrieve public key from server') + raise # Verify the JWT with the fetched public key try: diff --git a/CyberSource/utilities/flex/PublicKeyApiController.py b/CyberSource/utilities/flex/PublicKeyApiController.py index dd89f820..ae60d5a9 100644 --- a/CyberSource/utilities/flex/PublicKeyApiController.py +++ b/CyberSource/utilities/flex/PublicKeyApiController.py @@ -52,18 +52,14 @@ def fetch_public_key(kid, run_environment): # Use the same SSL configuration pattern as rest.py configuration = Configuration() - if configuration.ssl_ca_cert: - cert_reqs = ssl.CERT_REQUIRED - ca_certs = configuration.ssl_ca_cert - else: - # Use certifi bundle as fallback (same as rest.py) - cert_reqs = ssl.CERT_REQUIRED # ← Changed from CERT_NONE - ca_certs = certifi.where() # ← Use certifi instead of None + # Use certifi bundle as fallback (same as rest.py) + configuration.cert_reqs = ssl.CERT_REQUIRED + configuration.ca_certs = certifi.where() # Create urllib3 PoolManager with SSL configuration (matches rest.py pattern) http = urllib3.PoolManager( - cert_reqs=cert_reqs, - ca_certs=ca_certs + cert_reqs=configuration.cert_reqs, + ca_certs=configuration.ca_certs ) try: