diff --git a/CHANGELOG.md b/CHANGELOG.md index d7254fa..f6af5d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [#.#.#] - YYYY-MM-DD + +### Added + +- Support for Cross-Tab Session (CTS) extraction from `pxcts` cookie +- Support for JWT-based User Identifiers extraction from cookies or headers + +### Fixed + +- Decoded cookie (`px_cookie`) now included in risk API request for sensitive route calls +- Decoded cookie (`px_cookie`) now included in block activities when a valid cookie exists + ## [4.0.1] - 2025-11-16 ### Changed diff --git a/README.md b/README.md index 430fea0..ca5d048 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ * [Data-Enrichment](#data-enrichment) * [Enrich Custom Params](#enrich-custom-params) * [Login Credentials Extraction](#login-credentials-extraction) +* [JWT User Identifiers](#jwt-user-identifiers) * [Additional S2S Activity](#additional-s2s-activity) * [Logging](#logging) * [Module Mode](#module-mode) @@ -641,6 +642,45 @@ function extractCreds() { } ``` +### JWT User Identifiers + +This feature extracts user identity information from a JWT (JSON Web Token) present in a cookie or header, and sends it to PerimeterX as part of the risk evaluation and activity data. + +The extracted data includes: +- **App User ID** - A single field representing the user's identity +- **Additional Fields** - Optional extra fields from the JWT payload + +The enforcer also automatically extracts the Cross-Tab Session (CTS) value from the `pxcts` cookie if present. + +**Default:** + +All JWT configuration options are disabled by default (set to `null` or empty array). + +```php +$perimeterxConfig = [ + // Extract JWT from a cookie + 'px_jwt_cookie_name' => 'auth_token', + 'px_jwt_cookie_user_id_field_name' => 'sub', + 'px_jwt_cookie_additional_field_names' => ['session.id', 'account.type'], + + // Or extract JWT from a header (cookie takes precedence if both configured) + 'px_jwt_header_name' => 'Authorization', + 'px_jwt_header_user_id_field_name' => 'user.id', + 'px_jwt_header_additional_field_names' => ['tenant.id'] +]; +``` + +#### Configuration Options + +| Option | Type | Description | +|--------|------|-------------| +| `px_jwt_cookie_name` | string | Name of the cookie containing the JWT | +| `px_jwt_cookie_user_id_field_name` | string | Dot-notated path to the user ID field in the JWT payload | +| `px_jwt_cookie_additional_field_names` | array | List of dot-notated paths for additional fields to extract | +| `px_jwt_header_name` | string | Name of the header containing the JWT | +| `px_jwt_header_user_id_field_name` | string | Dot-notated path to the user ID field in the JWT payload | +| `px_jwt_header_additional_field_names` | array | List of dot-notated paths for additional fields to extract | + ### Additional S2S Activity To enhance detection on login credentials extraction endpoints, the following additional information is sent to PerimeterX diff --git a/examples/test-adapter.php b/examples/test-adapter.php new file mode 100644 index 0000000..2413cb8 --- /dev/null +++ b/examples/test-adapter.php @@ -0,0 +1,115 @@ + PHP SDK names +// null = keep the key as-is (SDK expects it with px_ prefix) +$CONFIG_MAP = [ + // Keys that need translation (spec tests use different names than SDK) + 'px_app_id' => 'app_id', + 'px_auth_token' => 'auth_token', + 'px_cookie_secret' => 'cookie_key', + 'px_module_enabled' => 'module_enabled', + 'px_blocking_score' => 'blocking_score', + 'px_module_mode' => 'module_mode', + 'px_backend_url' => 'perimeterx_server_host', + 'px_s2s_timeout' => 'api_timeout', + 'px_api_timeout' => 'api_timeout', + 'px_sensitive_routes' => 'sensitive_routes', + 'px_sensitive_headers' => 'sensitive_headers', + 'px_ip_headers' => 'ip_headers', + // Keys that keep px_ prefix (SDK expects it as-is) + 'px_first_party_enabled' => null, + 'px_cd_first_party_enabled' => null, + 'px_jwt_cookie_name' => null, + 'px_jwt_cookie_user_id_field_name' => null, + 'px_jwt_cookie_additional_field_names' => null, + 'px_jwt_header_name' => null, + 'px_jwt_header_user_id_field_name' => null, + 'px_jwt_header_additional_field_names' => null, + 'px_login_credentials_extraction_enabled' => null, + 'px_login_credentials_extraction' => null, + 'px_compromised_credentials_header' => null, + 'px_credentials_intelligence_version' => null, + 'px_additional_s2s_activity_header_enabled' => null, + 'px_automatic_additional_s2s_activity_enabled' => null, + 'px_send_raw_username_on_additional_s2s_activity' => null, + 'px_login_successful_reporting_method' => null, + 'px_login_successful_status' => null, + 'px_login_successful_header_name' => null, + 'px_login_successful_header_value' => null, +]; + +/** + * Translate spec test config (px_* names) to PHP SDK config names + */ +function translateConfig(array $config, array $configMap): array { + $translated = []; + + foreach ($config as $key => $value) { + if (array_key_exists($key, $configMap)) { + $mappedKey = $configMap[$key]; + $translated[$mappedKey ?? $key] = $value; + } elseif (strpos($key, 'px_') === 0) { + // Unmapped px_* keys: strip prefix + $translated[substr($key, 3)] = $value; + } else { + $translated[$key] = $value; + } + } + + // Translate module_mode string to SDK constant + if (isset($translated['module_mode']) && is_string($translated['module_mode'])) { + $mode = strtolower($translated['module_mode']); + if ($mode === 'monitor' || $mode === 'monitoring') { + $translated['module_mode'] = Perimeterx::$MONITOR_MODE; + } elseif ($mode === 'active_blocking' || $mode === 'blocking' || $mode === 'active') { + $translated['module_mode'] = Perimeterx::$ACTIVE_MODE; + } + } + + return $translated; +} + +// Load config from JSON file +$configFile = __DIR__ . '/enforcer_config.json'; +if (!file_exists($configFile)) { + http_response_code(200); + echo 'OK'; + exit; +} + +$fileConfig = json_decode(file_get_contents($configFile), true); +if ($fileConfig === null) { + http_response_code(200); + echo 'OK'; + exit; +} + +$config = translateConfig($fileConfig, $CONFIG_MAP); + +// Validate required config +if (empty($config['app_id']) || empty($config['cookie_key']) || empty($config['auth_token'])) { + http_response_code(200); + echo 'OK'; + exit; +} + +// Run the enforcer +try { + $px = Perimeterx::Instance($config); + $px->pxVerify(); +} catch (Exception $e) { + http_response_code(200); + echo 'OK'; +} diff --git a/px_metadata.json b/px_metadata.json index eb63c38..2bc13e9 100644 --- a/px_metadata.json +++ b/px_metadata.json @@ -26,6 +26,7 @@ "risk_api", "sensitive_headers", "sensitive_routes", - "vid_extraction" + "vid_extraction", + "user_identifiers" ] } \ No newline at end of file diff --git a/src/Perimeterx.php b/src/Perimeterx.php index 2866906..f88a627 100644 --- a/src/Perimeterx.php +++ b/src/Perimeterx.php @@ -32,6 +32,7 @@ use Perimeterx\CredentialsIntelligence\LoginSuccess\LoginSuccessfulReportingMethod; use Perimeterx\CredentialsIntelligence\LoginSuccess\LoginSuccessfulParserFactory; use Perimeterx\Utils\GuzzleHttpClient; +use Perimeterx\UserIdentifiers\JwtExtractor; final class Perimeterx { @@ -128,7 +129,13 @@ private function __construct(array $pxConfig = []) 'px_login_successful_reporting_method' => LoginSuccessfulReportingMethod::STATUS, 'px_login_successful_status' => 200, 'px_login_successful_header_name' => 'x-px-login-successful', - 'px_login_successful_header_value' => '1' + 'px_login_successful_header_value' => '1', + 'px_jwt_cookie_name' => null, + 'px_jwt_cookie_user_id_field_name' => null, + 'px_jwt_cookie_additional_field_names' => [], + 'px_jwt_header_name' => null, + 'px_jwt_header_user_id_field_name' => null, + 'px_jwt_header_additional_field_names' => [] ], $pxConfig); if (empty($this->pxConfig['logger'])) { @@ -163,7 +170,7 @@ public function pxVerify() $pxCtx = new PerimeterxContext($this->pxConfig, $additionalFields); $this->pxConfig['logger']->debug('Request context created successfully'); - + $this->enrichContextWithJwtData($pxCtx); $validator = new PerimeterxCookieValidator($pxCtx, $this->pxConfig); $cookie_valid = $validator->verify(); @@ -479,6 +486,39 @@ private function createAdditionalFields() { return $additionalFields; } + private function enrichContextWithJwtData($pxCtx) { + if (empty($this->pxConfig['px_jwt_cookie_name']) && empty($this->pxConfig['px_jwt_header_name'])) { + return; + } + + try { + $jwtExtractor = new JwtExtractor($this->pxConfig); + $cookies = []; + if (isset($_SERVER['HTTP_COOKIE'])) { + foreach (explode('; ', $_SERVER['HTTP_COOKIE']) as $rawcookie) { + if (!empty($rawcookie) && strpos($rawcookie, '=') !== false) { + list($k, $v) = explode('=', $rawcookie, 2); + $cookies[$k] = $v; + } + } + } + + $headers = $pxCtx->getHeaders(); + $jwtData = $jwtExtractor->extract($cookies, $headers); + + if (isset($jwtData)) { + if (isset($jwtData['app_user_id'])) { + $pxCtx->setAppUserId($jwtData['app_user_id']); + } + if (isset($jwtData['jwt_additional_fields'])) { + $pxCtx->setJwtAdditionalFields($jwtData['jwt_additional_fields']); + } + } + } catch (\Exception $e) { + $this->pxConfig['logger']->debug('Unable to extract JWT user identifiers: ' . $e->getMessage()); + } + } + private function extractGraphqlFields() { try { $this->pxConfig['logger']->debug("GraphQL endpoint identified"); diff --git a/src/PerimeterxActivitiesClient.php b/src/PerimeterxActivitiesClient.php index 62dfa37..91893eb 100644 --- a/src/PerimeterxActivitiesClient.php +++ b/src/PerimeterxActivitiesClient.php @@ -77,6 +77,14 @@ public function sendBlockActivity($pxCtx) $details['block_action'] = $pxCtx->getResponseBlockAction(); $details['simulated_block'] = $this->pxConfig['module_mode'] == Perimeterx::$MONITOR_MODE; + if ($pxCtx->getDecodedCookie()) { + $details['px_cookie'] = $pxCtx->getDecodedCookie(); + } + + if ($pxCtx->getCookieHmac()) { + $details['px_cookie_hmac'] = $pxCtx->getCookieHmac(); + } + $this->prepareActivitiesRequest("block", $pxCtx, $details); } @@ -132,6 +140,21 @@ private function addAdditionalFieldsToDetails(&$pxCtx, &$details) { $details['graphql_operation_type'] = $graphqlFields->getOperationType(); $details['graphql_operation_name'] = $graphqlFields->getOperationName(); } + + $crossTabSession = $pxCtx->getCrossTabSession(); + if (isset($crossTabSession)) { + $details['cross_tab_session'] = $crossTabSession; + } + + $appUserId = $pxCtx->getAppUserId(); + if (isset($appUserId)) { + $details['app_user_id'] = $appUserId; + } + + $jwtAdditionalFields = $pxCtx->getJwtAdditionalFields(); + if (isset($jwtAdditionalFields) && !empty($jwtAdditionalFields)) { + $details['jwt_additional_fields'] = $jwtAdditionalFields; + } } public function generateActivity($activityType, $pxCtx, $details) { diff --git a/src/PerimeterxContext.php b/src/PerimeterxContext.php index caf2075..981be38 100644 --- a/src/PerimeterxContext.php +++ b/src/PerimeterxContext.php @@ -54,8 +54,11 @@ public function __construct($pxConfig, $additionalFields = null) } $this->http_method = $_SERVER['REQUEST_METHOD']; $this->sensitive_route = $this->checkSensitiveRoutePrefix($pxConfig['sensitive_routes'], $this->uri); - $this->loginCredentials = array_key_exists('loginCredentials', $additionalFields) ? $additionalFields['loginCredentials'] : null; - $this->graphqlFields = array_key_exists('graphqlFields', $additionalFields) ? $additionalFields['graphqlFields'] : null; + if (is_array($additionalFields)) { + $this->loginCredentials = array_key_exists('loginCredentials', $additionalFields) ? $additionalFields['loginCredentials'] : null; + $this->graphqlFields = array_key_exists('graphqlFields', $additionalFields) ? $additionalFields['graphqlFields'] : null; + } + $this->requestId = PerimeterxUtils::createUuidV4(); } @@ -275,6 +278,21 @@ private function extractIP($pxConfig, $headers) * @var GraphqlFields */ protected $graphqlFields; + + /** + * @var string + */ + protected $cross_tab_session; + + /** + * @var string + */ + protected $app_user_id; + + /** + * @var array + */ + protected $jwt_additional_fields; /** * @return string @@ -605,6 +623,9 @@ private function explodeCookieToVersion($delimiter, $cookie) if ($k == '_pxhd' || $k == '_pxvid') { $this->px_cookies[$k] = $v; } + if ($k == 'pxcts') { + $this->cross_tab_session = $v; + } array_push($this->request_cookie_names, $k); } else { @@ -854,4 +875,39 @@ public function getRequestId() { public function areCredentialsCompromised() { return isset($this->pxde) && isset($this->pxde->breached_account) && $this->pxde->breached_account; } + + /** + * @return string|null + */ + public function getCrossTabSession() { + return $this->cross_tab_session; + } + + /** + * @return string|null + */ + public function getAppUserId() { + return $this->app_user_id; + } + + /** + * @param string $app_user_id + */ + public function setAppUserId($app_user_id) { + $this->app_user_id = $app_user_id; + } + + /** + * @return array|null + */ + public function getJwtAdditionalFields() { + return $this->jwt_additional_fields; + } + + /** + * @param array $jwt_additional_fields + */ + public function setJwtAdditionalFields($jwt_additional_fields) { + $this->jwt_additional_fields = $jwt_additional_fields; + } } diff --git a/src/PerimeterxLogger.php b/src/PerimeterxLogger.php index 7916fcb..a282b4b 100644 --- a/src/PerimeterxLogger.php +++ b/src/PerimeterxLogger.php @@ -23,7 +23,7 @@ public function __construct($pxConfig) { * Logs with an arbitrary level. * * @param mixed $level - * @param string $message + * @param string|\Stringable $message * @param array $context * * @return void diff --git a/src/PerimeterxRiskClient.php b/src/PerimeterxRiskClient.php index d8bfc5d..ea2055e 100644 --- a/src/PerimeterxRiskClient.php +++ b/src/PerimeterxRiskClient.php @@ -52,7 +52,7 @@ protected function formatHeaders() } /** - * @return long current time in milliseconds + * @return float current time in milliseconds */ protected function getTimeInMilliseconds(){ return round(microtime(true) * 1000); diff --git a/src/PerimeterxS2SValidator.php b/src/PerimeterxS2SValidator.php index bfe0d2c..ee9594b 100644 --- a/src/PerimeterxS2SValidator.php +++ b/src/PerimeterxS2SValidator.php @@ -99,7 +99,7 @@ private function prepareRiskRequestBody() { $requestBody['additional']['px_cookie_orig'] = $this->pxCtx->getPxCookie(); } - if (in_array($this->pxCtx->getS2SCallReason(), ['cookie_expired', 'cookie_validation_failed'])) { + if (in_array($this->pxCtx->getS2SCallReason(), ['cookie_expired', 'cookie_validation_failed', 'sensitive_route'])) { if ($this->pxCtx->getDecodedCookie()) { $requestBody['additional']['px_cookie'] = $this->pxCtx->getDecodedCookie(); } @@ -145,6 +145,21 @@ private function prepareRiskRequestBody() { $requestBody['additional']['graphql_operation_name'] = $graphqlFields->getOperationName(); } + $crossTabSession = $this->pxCtx->getCrossTabSession(); + if (isset($crossTabSession)) { + $requestBody['additional']['cross_tab_session'] = $crossTabSession; + } + + $appUserId = $this->pxCtx->getAppUserId(); + if (isset($appUserId)) { + $requestBody['additional']['app_user_id'] = $appUserId; + } + + $jwtAdditionalFields = $this->pxCtx->getJwtAdditionalFields(); + if (isset($jwtAdditionalFields) && !empty($jwtAdditionalFields)) { + $requestBody['additional']['jwt_additional_fields'] = $jwtAdditionalFields; + } + return $requestBody; } diff --git a/src/UserIdentifiers/JwtExtractor.php b/src/UserIdentifiers/JwtExtractor.php new file mode 100644 index 0000000..1565444 --- /dev/null +++ b/src/UserIdentifiers/JwtExtractor.php @@ -0,0 +1,175 @@ +pxConfig = $pxConfig; + $this->logger = $pxConfig['logger']; + } + + /** + * @param array $cookies + * @param array $headers + * @return array|null + */ + public function extract($cookies, $headers) + { + $result = $this->extractFromCookie($cookies); + if ($result !== null) { + return $result; + } + + return $this->extractFromHeader($headers); + } + + /** + * @param array $cookies + * @return array|null + */ + private function extractFromCookie($cookies) + { + $cookieName = $this->pxConfig['px_jwt_cookie_name']; + if (empty($cookieName) || !isset($cookies[$cookieName])) { + return null; + } + + $jwtToken = $cookies[$cookieName]; + $userIdFieldName = $this->pxConfig['px_jwt_cookie_user_id_field_name']; + $additionalFieldNames = $this->pxConfig['px_jwt_cookie_additional_field_names'] ?? []; + + return $this->extractJwtData($jwtToken, $userIdFieldName, $additionalFieldNames); + } + + /** + * @param array $headers + * @return array|null + */ + private function extractFromHeader($headers) + { + $headerName = $this->pxConfig['px_jwt_header_name']; + if (empty($headerName)) { + return null; + } + + $headersLower = array_change_key_case($headers, CASE_LOWER); + $headerNameLower = strtolower($headerName); + + if (!isset($headersLower[$headerNameLower]) || empty($headersLower[$headerNameLower])) { + return null; + } + + $jwtToken = $headersLower[$headerNameLower]; + $userIdFieldName = $this->pxConfig['px_jwt_header_user_id_field_name']; + $additionalFieldNames = $this->pxConfig['px_jwt_header_additional_field_names'] ?? []; + + return $this->extractJwtData($jwtToken, $userIdFieldName, $additionalFieldNames); + } + + /** + * @param string $jwtToken + * @param string|null $userIdFieldName + * @param array $additionalFieldNames + * @return array|null + */ + private function extractJwtData($jwtToken, $userIdFieldName, $additionalFieldNames) + { + try { + $payload = $this->decodeJwtPayload($jwtToken); + if ($payload === null) { + return null; + } + + $result = []; + + if (!empty($userIdFieldName)) { + $appUserId = $this->extractFieldValue($payload, $userIdFieldName); + if ($appUserId !== null) { + $result['app_user_id'] = $appUserId; + } + } + + if (!empty($additionalFieldNames)) { + $additionalFields = []; + foreach ($additionalFieldNames as $fieldName) { + $value = $this->extractFieldValue($payload, $fieldName); + if ($value !== null) { + $additionalFields[$fieldName] = $value; + } + } + if (!empty($additionalFields)) { + $result['jwt_additional_fields'] = $additionalFields; + } + } + + return !empty($result) ? $result : null; + } catch (\Exception $e) { + $this->logger->debug('Unable to extract JWT data: ' . $e->getMessage()); + return null; + } + } + + /** + * @param string $jwtToken + * @return array|null + */ + private function decodeJwtPayload($jwtToken) + { + $parts = explode('.', $jwtToken); + if (count($parts) < 3) { + return null; + } + + $encodedPayload = $parts[1]; + $base64 = strtr($encodedPayload, '-_', '+/'); + + $padLength = 4 - (strlen($base64) % 4); + if ($padLength < 4) { + $base64 .= str_repeat('=', $padLength); + } + + $decoded = base64_decode($base64, true); + if ($decoded === false) { + return null; + } + + $payload = json_decode($decoded, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + return $payload; + } + + /** + * @param array $payload + * @param string $fieldName + * @return mixed|null + */ + private function extractFieldValue($payload, $fieldName) + { + $keys = explode('.', $fieldName); + $value = $payload; + + foreach ($keys as $key) { + if (!is_array($value) || !array_key_exists($key, $value)) { + return null; + } + $value = $value[$key]; + } + + return $value; + } +}