Skip to content
This repository was archived by the owner on Oct 29, 2025. It is now read-only.
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ 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/).

## [3.8.0] - 2023-01-25

### Added
- Support User Identifiers: CTS and JWT.

## [3.7.0] - 2023-01-15

### Added
Expand Down
34 changes: 31 additions & 3 deletions lib/pxapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ const { ModuleMode } = require('./enums/ModuleMode');
const PassReason = require('./enums/PassReason');
const ScoreEvaluateAction = require('./enums/ScoreEvaluateAction');
const S2SErrorReason = require('./enums/S2SErrorReason');
const { CI_USERNAME_FIELD, CI_PASSWORD_FIELD, CI_VERSION_FIELD, CI_SSO_STEP_FIELD,
GQL_OPERATIONS_FIELD

const {
CI_USERNAME_FIELD,
CI_PASSWORD_FIELD,
CI_VERSION_FIELD,
CI_SSO_STEP_FIELD,
GQL_OPERATIONS_FIELD,
JWT_ADDITIONAL_FIELDS_FIELD_NAME,
APP_USER_ID_FIELD_NAME,
CROSS_TAB_SESSION,
} = require('./utils/constants');
const { CIVersion } = require('./enums/CIVersion');

Expand Down Expand Up @@ -78,11 +86,31 @@ function buildRequestData(ctx, config) {
}
}

if (ctx.jwt) {
const { userID, additionalFields } = ctx.jwt;

if (userID) {
data.additional[APP_USER_ID_FIELD_NAME] = userID;
}

if (additionalFields) {
data.additional[JWT_ADDITIONAL_FIELDS_FIELD_NAME] = additionalFields;
}
}

if (ctx.cts) {
data.additional[CROSS_TAB_SESSION] = ctx.cts;
}

if (ctx.s2sCallReason === 'cookie_decryption_failed') {
data.additional.px_orig_cookie = ctx.getCookie(); //No need strigify, already a string
}

if (ctx.s2sCallReason === 'cookie_expired' || ctx.s2sCallReason === 'cookie_validation_failed' || ctx.s2sCallReason === 'sensitive_route') {
if (
ctx.s2sCallReason === 'cookie_expired' ||
ctx.s2sCallReason === 'cookie_validation_failed' ||
ctx.s2sCallReason === 'sensitive_route'
) {
data.additional.px_cookie = JSON.stringify(ctx.decodedCookie);
}

Expand Down
37 changes: 28 additions & 9 deletions lib/pxclient.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ const {
CI_SSO_STEP_FIELD,
CI_RAW_USERNAME_FIELD,
CI_CREDENTIALS_COMPROMISED_FIELD,
GQL_OPERATIONS_FIELD
GQL_OPERATIONS_FIELD,
APP_USER_ID_FIELD_NAME,
JWT_ADDITIONAL_FIELDS_FIELD_NAME,
CROSS_TAB_SESSION,
} = require('./utils/constants');

class PxClient {
Expand Down Expand Up @@ -64,6 +67,22 @@ class PxClient {
if (ctx.graphqlData) {
details[GQL_OPERATIONS_FIELD] = ctx.graphqlData;
}

if (ctx.jwt) {
const { userID, additionalFields } = ctx.jwt;

if (userID) {
details[APP_USER_ID_FIELD_NAME] = userID;
}

if (additionalFields) {
details[JWT_ADDITIONAL_FIELDS_FIELD_NAME] = additionalFields;
}
}

if (ctx.cts) {
details[CROSS_TAB_SESSION] = ctx.cts;
}
}

/**
Expand All @@ -85,11 +104,11 @@ class PxClient {

sendEnforcerTelemetry(updateReason, config) {
const details = {
'enforcer_configs': pxUtil.filterConfig(config),
'node_name': os.hostname(),
'os_name': os.platform(),
'update_reason': updateReason,
'module_version': config.MODULE_VERSION
enforcer_configs: pxUtil.filterConfig(config),
node_name: os.hostname(),
os_name: os.platform(),
update_reason: updateReason,
module_version: config.MODULE_VERSION,
};

const pxData = {};
Expand Down Expand Up @@ -119,9 +138,9 @@ class PxClient {

createHeaders(config, additionalHeaders = {}) {
return {
'Authorization': 'Bearer ' + config.AUTH_TOKEN,
Authorization: 'Bearer ' + config.AUTH_TOKEN,
'Content-Type': 'application/json',
...additionalHeaders
...additionalHeaders,
};
}

Expand All @@ -135,7 +154,7 @@ class PxClient {
[CI_VERSION_FIELD]: loginCredentials && loginCredentials.version,
[CI_RAW_USERNAME_FIELD]: loginCredentials && loginCredentials.rawUsername,
[CI_SSO_STEP_FIELD]: loginCredentials && loginCredentials.ssoStep,
...additionalDetails
...additionalDetails,
};

if (!config.SEND_RAW_USERNAME_ON_ADDITIONAL_S2S_ACTIVITY || !details.credentials_compromised) {
Expand Down
22 changes: 20 additions & 2 deletions lib/pxconfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ class PxConfig {
['LOGIN_SUCCESSFUL_BODY_REGEX', 'px_login_successful_body_regex'],
['LOGIN_SUCCESSFUL_CUSTOM_CALLBACK', 'px_login_successful_custom_callback'],
['MODIFY_CONTEXT', 'px_modify_context'],
['JWT_COOKIE_NAME', 'px_jwt_cookie_name'],
['JWT_COOKIE_USER_ID_FIELD_NAME', 'px_jwt_cookie_user_id_field_name'],
['JWT_COOKIE_ADDITIONAL_FIELD_NAMES', 'px_jwt_cookie_additional_field_names'],
['JWT_HEADER_NAME', 'px_jwt_header_name'],
['JWT_HEADER_USER_ID_FIELD_NAME', 'px_jwt_header_user_id_field_name'],
['JWT_HEADER_ADDITIONAL_FIELD_NAMES', 'px_jwt_header_additional_field_names'],
];

configKeyMapping.forEach(([targetKey, sourceKey]) => {
Expand Down Expand Up @@ -336,7 +342,13 @@ function pxDefaultConfig() {
LOGIN_SUCCESSFUL_BODY_REGEX: '',
LOGIN_SUCCESSFUL_CUSTOM_CALLBACK: null,
MODIFY_CONTEXT: null,
GRAPHQL_ROUTES: ['^/graphql$']
GRAPHQL_ROUTES: ['^/graphql$'],
JWT_COOKIE_NAME: '',
JWT_COOKIE_USER_ID_FIELD_NAME: '',
JWT_COOKIE_ADDITIONAL_FIELD_NAMES: [],
JWT_HEADER_NAME: '',
JWT_HEADER_USER_ID_FIELD_NAME: '',
JWT_HEADER_ADDITIONAL_FIELD_NAMES: [],
};
}

Expand Down Expand Up @@ -398,7 +410,13 @@ const allowedConfigKeys = [
'px_login_successful_body_regex',
'px_login_successful_custom_callback',
'px_modify_context',
'px_graphql_routes'
'px_graphql_routes',
'px_jwt_cookie_name',
'px_jwt_cookie_user_id_field_name',
'px_jwt_cookie_additional_field_names',
'px_jwt_header_name',
'px_jwt_header_user_id_field_name',
'px_jwt_header_additional_field_names',
];

module.exports = PxConfig;
31 changes: 22 additions & 9 deletions lib/pxcontext.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const { v4: uuidv4 } = require('uuid');

const { CookieOrigin } = require('./enums/CookieOrigin');
const pxUtil = require('./pxutil');
const pxJWT = require('./pxjwt');

class PxContext {
constructor(config, req, additionalFields) {
Expand Down Expand Up @@ -30,6 +31,7 @@ class PxContext {
this.cookieOrigin = CookieOrigin.COOKIE;
this.additionalFields = additionalFields || {};
this.signedFields = [this.userAgent];

const mobileHeader = this.headers[mobileSdkHeader];
if (mobileHeader !== undefined) {
this.signedFields = null;
Expand All @@ -51,23 +53,36 @@ class PxContext {
} else if ((key === '_pxvid' || key === 'pxvid') && vidRegex.test(cookies[key])) {
this.vid = cookies[key];
this.vidSource = 'vid_cookie';
} else if (key === 'pxcts') {
this.cts = cookies[key];
} else if (key.match(/^_px.+$/)) {
this.cookies[key] = cookies[key];
}
});
}
if (pxUtil.isGraphql(req, config)) {
config.logger.debug('Graphql route detected');
this.graphqlData = this.getGraphqlDataFromBody(req.body).filter(x => x).map(
operation => operation && {
...operation,
sensitive: pxUtil.isSensitiveGraphqlOperation(operation, config),
});
this.sensitiveGraphqlOperation = this.graphqlData.some(operation => operation && operation.sensitive);
this.graphqlData = this.getGraphqlDataFromBody(req.body)
.filter((x) => x)
.map(
(operation) =>
operation && {
...operation,
sensitive: pxUtil.isSensitiveGraphqlOperation(operation, config),
},
);
this.sensitiveGraphqlOperation = this.graphqlData.some((operation) => operation && operation.sensitive);
}
if (process.env.AWS_REGION) {
this.serverInfoRegion = process.env.AWS_REGION;
}

if (config.JWT_COOKIE_NAME || config.JWT_HEADER_NAME) {
const token = req.cookies[config.JWT_COOKIE_NAME] || req.headers[config.JWT_HEADER_NAME];
if (token) {
this.jwt = pxJWT.extractJWTData(config, token);
}
}
}

getGraphqlDataFromBody(body) {
Expand All @@ -77,9 +92,7 @@ class PxContext {
} else if (typeof body === 'object') {
jsonBody = body;
}
return Array.isArray(jsonBody) ?
jsonBody.map(pxUtil.getGraphqlData) :
[pxUtil.getGraphqlData(jsonBody)];
return Array.isArray(jsonBody) ? jsonBody.map(pxUtil.getGraphqlData) : [pxUtil.getGraphqlData(jsonBody)];
}

getCookie() {
Expand Down
59 changes: 59 additions & 0 deletions lib/pxjwt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
const { TOKEN_SEPARATOR } = require('./utils/constants');

function getJWTPayload(pxConfig, token) {
try {
const encodedPayload = token.split(TOKEN_SEPARATOR)[1];
if (encodedPayload) {
const base64Payload = encodedPayload.replace('-', '+').replace('_', '/');
const payload = Buffer.from(base64Payload, 'base64').toString();
return JSON.parse(payload);
}
} catch (e) {
pxConfig.logger.debug(`Failed to parse JWT token ${token}: ${e.message} `);
}

return null;
}

function getJWTData(pxConfig, payload) {
let additionalFields = null;

try {
const userFieldName = pxConfig.JWT_COOKIE_USER_ID_FIELD_NAME || pxConfig.JWT_HEADER_USER_ID_FIELD_NAME;
const userID = payload[userFieldName];

const additionalFieldsConfig =
pxConfig.JWT_COOKIE_ADDITIONAL_FIELD_NAMES.length > 0
? pxConfig.JWT_COOKIE_ADDITIONAL_FIELD_NAMES
: pxConfig.JWT_HEADER_ADDITIONAL_FIELD_NAMES;

if (additionalFieldsConfig && additionalFieldsConfig.length > 0) {
additionalFields = additionalFieldsConfig.reduce((matchedFields, fieldName) => {
if (payload[fieldName]) {
matchedFields[fieldName] = payload[fieldName];
}
return matchedFields;
}, {});
}

return { userID, additionalFields };
} catch (e) {
pxConfig.logger.debug(`Failed to extract JWT token ${payload}: ${e.message} `);
}

return null;
}

function extractJWTData(pxConfig, token) {
const payload = getJWTPayload(pxConfig, token);

if (!payload) {
return null;
}

return getJWTData(pxConfig, payload);
}

module.exports = {
extractJWTData,
};
20 changes: 10 additions & 10 deletions lib/pxutil.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,8 @@ function generateHMAC(cookieSecret, payload) {

function isReqInMonitorMode(pxConfig, pxCtx) {
return (
(pxConfig.MODULE_MODE === ModuleMode.MONITOR && !pxCtx.shouldBypassMonitor && !pxCtx.enforcedRoute) || (pxCtx.monitoredRoute && !pxCtx.shouldBypassMonitor)
(pxConfig.MODULE_MODE === ModuleMode.MONITOR && !pxCtx.shouldBypassMonitor && !pxCtx.enforcedRoute) ||
(pxCtx.monitoredRoute && !pxCtx.shouldBypassMonitor)
);
}

Expand Down Expand Up @@ -277,7 +278,7 @@ function isGraphql(req, config) {
return false;
}
try {
return routes.some(r => new RegExp(r).test(req.baseUrl || '' + req.path));
return routes.some((r) => new RegExp(r).test(req.baseUrl || '' + req.path));
} catch (e) {
config.logger.error(`Failed to process graphql routes. exception: ${e}`);
return false;
Expand Down Expand Up @@ -311,8 +312,10 @@ function isSensitiveGraphqlOperation(graphqlData, config) {
if (!graphqlData) {
return false;
} else {
return (config.SENSITIVE_GRAPHQL_OPERATION_TYPES.includes(graphqlData.type) ||
config.SENSITIVE_GRAPHQL_OPERATION_NAMES.includes(graphqlData.name));
return (
config.SENSITIVE_GRAPHQL_OPERATION_TYPES.includes(graphqlData.type) ||
config.SENSITIVE_GRAPHQL_OPERATION_NAMES.includes(graphqlData.name)
);
}
}

Expand All @@ -328,19 +331,16 @@ function getGraphqlData(graphqlBodyObject) {
return null;
}

const selectedOperationName = graphqlBodyObject['operationName'] ||
(Object.keys(parsedData).length === 1 && Object.keys(parsedData)[0]);
const selectedOperationName =
graphqlBodyObject['operationName'] || (Object.keys(parsedData).length === 1 && Object.keys(parsedData)[0]);

if (!selectedOperationName || !parsedData[selectedOperationName]) {
return null;
}

const variables = extractVariables(graphqlBodyObject.variables);

return new GraphqlData(parsedData[selectedOperationName],
selectedOperationName,
variables,
);
return new GraphqlData(parsedData[selectedOperationName], selectedOperationName, variables);
}

// input: object representing variables
Expand Down
16 changes: 13 additions & 3 deletions lib/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ const CI_SSO_STEP_FIELD = 'sso_step';
const CI_CREDENTIALS_COMPROMISED_FIELD = 'credentials_compromised';

const GQL_OPERATIONS_FIELD = 'graphql_operations';
const EMAIL_ADDRESS_REGEX = /^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
const EMAIL_ADDRESS_REGEX =
/^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
const HASH_ALGORITHM = { SHA256: 'sha256' };

const TOKEN_SEPARATOR = '.';
const APP_USER_ID_FIELD_NAME = 'app_user_id';
const JWT_ADDITIONAL_FIELDS_FIELD_NAME = 'jwt_additional_fields';
const CROSS_TAB_SESSION = 'cross_tab_session';

module.exports = {
MILLISECONDS_IN_SECOND,
SECONDS_IN_MINUTE,
Expand All @@ -49,5 +55,9 @@ module.exports = {
CI_CREDENTIALS_COMPROMISED_FIELD,
GQL_OPERATIONS_FIELD,
EMAIL_ADDRESS_REGEX,
HASH_ALGORITHM
};
HASH_ALGORITHM,
TOKEN_SEPARATOR,
APP_USER_ID_FIELD_NAME,
JWT_ADDITIONAL_FIELDS_FIELD_NAME,
CROSS_TAB_SESSION,
};