Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
115 changes: 115 additions & 0 deletions examples/test-adapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?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(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';
}
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"
]
}
44 changes: 42 additions & 2 deletions 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 @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
23 changes: 23 additions & 0 deletions src/PerimeterxActivitiesClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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) {
Expand Down
60 changes: 58 additions & 2 deletions src/PerimeterxContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
}
Loading