Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ 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
Comment thread
DorAshkenaziHuman marked this conversation as resolved.
- Support for JWT-based User Identifiers extraction from cookies or headers

### Fixed

- Decoded cookie now included in risk API request for sensitive route calls
Comment thread
DorAshkenaziHuman marked this conversation as resolved.
Outdated

## [4.0.1] - 2025-11-16

### Changed
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -641,6 +642,45 @@ function extractCreds() {
}
```

### <a name="jwt-user-identifiers"></a> 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 |

### <a name="additional-s2s-activity"></a> Additional S2S Activity

To enhance detection on login credentials extraction endpoints, the following additional information is sent to PerimeterX
Expand Down
122 changes: 122 additions & 0 deletions examples/test-adapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php
/**
* Test Adapter for PerimeterX Enforcer Spec Tests
*
* Loads configuration from enforcer_config.json and runs pxVerify.
* Translates spec test config names (px_*) to PHP SDK config names.
*
* Usage: php -S localhost:3000 test-adapter.php
*/

require __DIR__ . "/../vendor/autoload.php";

use Perimeterx\Perimeterx;

// Config key translation: spec test names (px_*) => 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(500);
Comment thread
DorAshkenaziHuman marked this conversation as resolved.
Outdated
header('Content-Type: application/json');
echo json_encode(['error' => 'enforcer_config.json not found']);
exit;
}

$fileConfig = json_decode(file_get_contents($configFile), true);
if ($fileConfig === null) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => 'Failed to parse enforcer_config.json']);
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(500);
Comment thread
DorAshkenaziHuman marked this conversation as resolved.
Outdated
header('Content-Type: application/json');
echo json_encode([
'error' => 'Missing required configuration',
'required' => ['px_app_id', 'px_cookie_secret', 'px_auth_token']
]);
exit;
}

// Run the enforcer
try {
$px = Perimeterx::Instance($config);
$px->pxVerify();
} catch (Exception $e) {
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['error' => $e->getMessage()]);
}
3 changes: 2 additions & 1 deletion px_metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"risk_api",
"sensitive_headers",
"sensitive_routes",
"vid_extraction"
"vid_extraction",
"user_identifiers"
]
}
41 changes: 40 additions & 1 deletion src/Perimeterx.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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'])) {
Expand Down Expand Up @@ -476,9 +483,41 @@ private function createAdditionalFields() {
}
}

$jwtData = $this->extractJwtUserIdentifiers();
if (isset($jwtData)) {
$additionalFields['jwtData'] = $jwtData;
}

return $additionalFields;
}

private function extractJwtUserIdentifiers() {
if (empty($this->pxConfig['px_jwt_cookie_name']) && empty($this->pxConfig['px_jwt_header_name'])) {
return null;
}

Comment thread
DorAshkenaziHuman marked this conversation as resolved.
Outdated
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;
}
Comment thread
DorAshkenaziHuman marked this conversation as resolved.
}
}

$headers = PerimeterxUtils::getAllHeaders();
Comment thread
DorAshkenaziHuman marked this conversation as resolved.
Outdated

return $jwtExtractor->extract($cookies, $headers);
} catch (\Exception $e) {
$this->pxConfig['logger']->debug('Unable to extract JWT user identifiers: ' . $e->getMessage());
return null;
}
}

private function extractGraphqlFields() {
try {
$this->pxConfig['logger']->debug("GraphQL endpoint identified");
Expand Down
15 changes: 15 additions & 0 deletions src/PerimeterxActivitiesClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,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) {
Expand Down
64 changes: 64 additions & 0 deletions src/PerimeterxContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ public function __construct($pxConfig, $additionalFields = null)
$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 (array_key_exists('jwtData', $additionalFields) && isset($additionalFields['jwtData'])) {
$jwtData = $additionalFields['jwtData'];
Comment thread
DorAshkenaziHuman marked this conversation as resolved.
Outdated
if (isset($jwtData['app_user_id'])) {
$this->app_user_id = $jwtData['app_user_id'];
}
if (isset($jwtData['jwt_additional_fields'])) {
$this->jwt_additional_fields = $jwtData['jwt_additional_fields'];
}
}

$this->requestId = PerimeterxUtils::createUuidV4();
}

Expand Down Expand Up @@ -275,6 +286,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
Expand Down Expand Up @@ -605,6 +631,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 {
Expand Down Expand Up @@ -854,4 +883,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;
}
}
2 changes: 1 addition & 1 deletion src/PerimeterxLogger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/PerimeterxRiskClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ protected function formatHeaders()
}

/**
* @return long current time in milliseconds
* @return float current time in milliseconds
Comment thread
DorAshkenaziHuman marked this conversation as resolved.
*/
protected function getTimeInMilliseconds(){
return round(microtime(true) * 1000);
Expand Down
Loading