diff --git a/CyberSource/utilities/flex/CaptureContextParsingUtility.py b/CyberSource/utilities/flex/CaptureContextParsingUtility.py new file mode 100644 index 00000000..69280761 --- /dev/null +++ b/CyberSource/utilities/flex/CaptureContextParsingUtility.py @@ -0,0 +1,154 @@ +# 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 + """ + 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, 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, kid, run_environment, cache) + return parsed_jwt['payload'] + + +def _fetch_public_key_and_verify(jwt_value, 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 + 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: + # 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): + 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('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 + + # 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') diff --git a/CyberSource/utilities/flex/PublicKeyApiController.py b/CyberSource/utilities/flex/PublicKeyApiController.py new file mode 100644 index 00000000..ae60d5a9 --- /dev/null +++ b/CyberSource/utilities/flex/PublicKeyApiController.py @@ -0,0 +1,119 @@ +# 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 json +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 +from CyberSource.configuration import Configuration + + +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: + RSAPublicKey: The RSA public key object from cryptography library + + Raises: + ValueError: If kid or run_environment is missing + 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 + """ + 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' + } + + # Use the same SSL configuration pattern as rest.py + configuration = Configuration() + # 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=configuration.cert_reqs, + ca_certs=configuration.ca_certs + ) + + try: + # Make the HTTP GET request + response = http.request('GET', url, headers=headers) + + # Check if the request was successful + 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 + + # 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') + + # Use response data directly as JSON string + jwk_json_string = response_data + + # 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 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 urllib3.exceptions.MaxRetryError as retry_error: + error = Exception(f'No response received - Failed to fetch public key for kid: {kid}') + error.original_error = retry_error + raise 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: + # 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..8db1cdf1 --- /dev/null +++ b/authenticationsdk/util/jwt/JWTUtility.py @@ -0,0 +1,294 @@ +# 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() +} + + +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 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) + + +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('Invalid public key format. Expected JWK object or JSON string.') + + if jwk_key.get('kty') != 'RSA': + 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('Invalid RSA JWK: missing required parameters (n, e)') + + 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('Malformed JWT : JWT provided does not conform to the proper structure for JWT') + + 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('No public key provided') + + 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('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( + 'Unsupported JWT algorithm: {}. Supported algorithms: {}'.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' +]