Skip to content

Commit e5ad592

Browse files
authored
Merge pull request #179 from CyberSource/adding-cybsJwtUtility
Added utility for handling JWTs
2 parents 92010ce + c112b66 commit e5ad592

File tree

8 files changed

+533
-1
lines changed

8 files changed

+533
-1
lines changed

generator/cybersource-javascript-template/index.mustache

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,5 +101,6 @@
101101
exports.ExternalLoggerWrapper = require('./authentication/logging/ExternalLoggerWrapper.js');
102102
exports.JWEUtility = require('./utilities/JWEUtility.js');
103103
exports.SdkTracker = require('./utilities/tracking/SdkTracker.js');
104+
exports.CaptureContextParsingUtility = require('./utilities/capturecontext/CaptureContextParsingUtility.js');
104105
return exports;<={{ }}=>
105106
}));

src/authentication/util/Cache.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,18 @@ function validateCertificateExpiry(certificate, keyAlias, cacheKey, merchantConf
242242
}
243243
}
244244
};
245+
246+
exports.addPublicKeyToCache = function(runEnvironment, keyId, publicKey) {
247+
const cacheKey = Constants.PUBLIC_KEY_CACHE_IDENTIFIER + "_" + runEnvironment + "_" + keyId;
248+
cache.put(cacheKey, publicKey);
249+
};
250+
251+
exports.getPublicKeyFromCache = function(runEnvironment, keyId) {
252+
const cacheKey = Constants.PUBLIC_KEY_CACHE_IDENTIFIER + "_" + runEnvironment + "_" + keyId;
253+
254+
if (cache.size() === 0 || !cache.get(cacheKey)) {
255+
throw new Error("Public key not found in cache for [" + runEnvironment + ", " + keyId + "]");
256+
}
257+
258+
return cache.get(cacheKey);
259+
};

src/authentication/util/Constants.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,5 +108,6 @@ module.exports = {
108108
STATUS500 : "Internal Server Error",
109109
STATUS502 : "Bad Gateway",
110110
STATUS503 : "Service Unavailable",
111-
STATUS504 : "Gateway Timeout"
111+
STATUS504 : "Gateway Timeout",
112+
PUBLIC_KEY_CACHE_IDENTIFIER : "FlexV2PublicKeys"
112113
};
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict';
2+
3+
function createCustomError(name) {
4+
function CustomError(message, cause) {
5+
const instance = Reflect.construct(Error, [message], this.constructor);
6+
7+
Reflect.setPrototypeOf(instance, Reflect.getPrototypeOf(this));
8+
9+
instance.name = name;
10+
11+
Error.captureStackTrace(instance, this.constructor);
12+
13+
if (cause) {
14+
instance.cause = cause;
15+
if (cause.stack) {
16+
instance.stack = instance.stack + '\nCaused by: ' + cause.stack;
17+
}
18+
}
19+
20+
return instance;
21+
}
22+
23+
CustomError.prototype = Object.create(Error.prototype, {
24+
constructor: {
25+
value: CustomError,
26+
enumerable: false,
27+
writable: true,
28+
configurable: true
29+
},
30+
name: {
31+
value: name,
32+
enumerable: false,
33+
writable: true,
34+
configurable: true
35+
}
36+
});
37+
38+
Reflect.setPrototypeOf(CustomError, Error);
39+
return CustomError;
40+
}
41+
42+
/**
43+
* InvalidJwkException - Error class for invalid JWK (JSON Web Key)
44+
* @param {string} message - Error message describing the invalid JWK
45+
* @param {Error} [cause] - Optional underlying cause of the error
46+
* @constructor
47+
*/
48+
exports.InvalidJwkException = createCustomError('InvalidJwkException');
49+
50+
/**
51+
* InvalidJwtException - Error class for invalid JWT token
52+
* @param {string} message - Error message describing the invalid JWT token
53+
* @param {Error} [cause] - Optional underlying cause of the error
54+
* @constructor
55+
*/
56+
exports.InvalidJwtException = createCustomError('InvalidJwtException');
57+
58+
/**
59+
* JwtSignatureValidationException - Error class for JWT signature validation failures
60+
* @param {string} message - Error message describing the signature validation failure
61+
* @param {Error} [cause] - Optional underlying cause of the error
62+
* @constructor
63+
*/
64+
exports.JwtSignatureValidationException = createCustomError('JwtSignatureValidationException');
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
'use strict'
2+
3+
const forge = require('node-forge');
4+
const crypto = require('crypto');
5+
const JWTExceptions = require('./JWTExceptions.js');
6+
7+
// Supported JWT algorithms and their corresponding hash algorithms
8+
const SUPPORTED_ALGORITHMS = {
9+
'RS256': 'sha256',
10+
'RS384': 'sha384',
11+
'RS512': 'sha512'
12+
};
13+
14+
// Error messages constants
15+
const ERROR_MESSAGES = {
16+
UNSUPPORTED_ALGORITHM: (algorithm) =>
17+
`Unsupported JWT algorithm: ${algorithm}. Supported algorithms: ${Object.keys(SUPPORTED_ALGORITHMS).join(', ')}`,
18+
MISSING_ALGORITHM: 'JWT header missing algorithm (alg) field',
19+
NO_PUBLIC_KEY: 'No public key found',
20+
INVALID_PUBLIC_KEY_FORMAT: 'Invalid public key format. Expected JWK object or JSON string.',
21+
INVALID_RSA_KEY: 'Public key must be an RSA key (kty: RSA)',
22+
MISSING_RSA_PARAMS: 'Invalid RSA JWK: missing required parameters (n, e)'
23+
};
24+
25+
/**
26+
* Decodes a base64url encoded string to a JSON object
27+
* @param {string} base64urlString - The base64url encoded string
28+
* @param {string} partName - Name of the JWT part for error reporting (e.g., 'header', 'payload')
29+
* @returns {Object} - The decoded JSON object
30+
* @throws {InvalidJwtException} - If decoding or parsing fails
31+
* @private
32+
*/
33+
function decodeJwtPart(base64urlString, partName) {
34+
try {
35+
const jsonString = Buffer.from(base64urlString, 'base64url').toString('utf8');
36+
return JSON.parse(jsonString);
37+
} catch (decodeErr) {
38+
if (decodeErr.name === 'SyntaxError') {
39+
throw new JWTExceptions.InvalidJwtException(`Invalid JSON in JWT ${partName}`, decodeErr);
40+
}
41+
throw new JWTExceptions.InvalidJwtException(`Failed to decode JWT ${partName} from base64url`, decodeErr);
42+
}
43+
}
44+
45+
/**
46+
* Validates and parses a JWK public key
47+
* @param {Object|string} publicKey - The RSA public key (JWK object or JSON string)
48+
* @returns {Object} - The validated JWK object
49+
* @throws {InvalidJwkException} - If the public key is invalid
50+
* @private
51+
*/
52+
function validateAndParseJwk(publicKey) {
53+
let jwkKey;
54+
55+
if (typeof publicKey === 'string') {
56+
try {
57+
jwkKey = JSON.parse(publicKey);
58+
} catch (parseErr) {
59+
throw new JWTExceptions.InvalidJwkException('Invalid public key JSON format', parseErr);
60+
}
61+
} else if (typeof publicKey === 'object' && publicKey !== null && publicKey.kty) {
62+
jwkKey = publicKey;
63+
} else {
64+
throw new JWTExceptions.InvalidJwkException(ERROR_MESSAGES.INVALID_PUBLIC_KEY_FORMAT);
65+
}
66+
67+
if (jwkKey.kty !== 'RSA') {
68+
throw new JWTExceptions.InvalidJwkException(ERROR_MESSAGES.INVALID_RSA_KEY);
69+
}
70+
71+
if (!jwkKey.n || !jwkKey.e) {
72+
throw new JWTExceptions.InvalidJwkException(ERROR_MESSAGES.MISSING_RSA_PARAMS);
73+
}
74+
75+
return jwkKey;
76+
}
77+
78+
/**
79+
* Converts JWK RSA parameters to PEM format public key
80+
* @param {Object} jwkKey - The JWK object with RSA parameters
81+
* @returns {string} - The PEM formatted public key
82+
* @throws {InvalidJwkException} - If key conversion fails
83+
* @private
84+
*/
85+
function convertJwkToPem(jwkKey) {
86+
let n, e;
87+
try {
88+
n = Buffer.from(jwkKey.n, 'base64url');
89+
e = Buffer.from(jwkKey.e, 'base64url');
90+
} catch (decodeErr) {
91+
92+
throw new JWTExceptions.InvalidJwkException('Invalid base64url encoding in JWK parameters', decodeErr);
93+
}
94+
95+
let publicKeyForge;
96+
try {
97+
publicKeyForge = forge.pki.rsa.setPublicKey(
98+
forge.util.createBuffer(n).toHex(),
99+
forge.util.createBuffer(e).toHex()
100+
);
101+
} catch (keyErr) {
102+
throw new JWTExceptions.InvalidJwkException('Failed to create RSA public key from JWK', keyErr);
103+
}
104+
105+
try {
106+
return forge.pki.publicKeyToPem(publicKeyForge);
107+
} catch (pemErr) {
108+
throw new JWTExceptions.InvalidJwkException('Failed to convert public key to PEM format', pemErr);
109+
}
110+
}
111+
112+
/**
113+
* Parses a JWT token and extracts its header, payload, and signature components
114+
* @param {string} jwtToken - The JWT token to parse
115+
* @returns {Object} - Object containing header, payload, signature, and raw parts
116+
* @throws {InvalidJwtException} - If the JWT token is invalid or malformed
117+
*/
118+
exports.parse = function (jwtToken) {
119+
if (!jwtToken) {
120+
throw new JWTExceptions.InvalidJwtException('JWT token is null or undefined');
121+
}
122+
123+
if (typeof jwtToken !== 'string') {
124+
throw new JWTExceptions.InvalidJwtException('JWT token must be a string');
125+
}
126+
127+
const tokenParts = jwtToken.split('.');
128+
if (tokenParts.length !== 3) {
129+
throw new JWTExceptions.InvalidJwtException('Invalid JWT token format: expected 3 parts separated by dots');
130+
}
131+
132+
// Validate that all parts are non-empty
133+
if (!tokenParts[0] || !tokenParts[1] || !tokenParts[2]) {
134+
throw new JWTExceptions.InvalidJwtException('Invalid JWT token: one or more parts are empty');
135+
}
136+
137+
try {
138+
// Use helper function for consistent base64url decoding
139+
const header = decodeJwtPart(tokenParts[0], 'header');
140+
const payload = decodeJwtPart(tokenParts[1], 'payload');
141+
const signature = tokenParts[2];
142+
143+
return {
144+
header,
145+
payload,
146+
signature,
147+
// Include raw base64url parts for signature verification
148+
rawHeader: tokenParts[0],
149+
rawPayload: tokenParts[1]
150+
};
151+
} catch (err) {
152+
// Re-throw our custom exceptions
153+
if (err.name === 'InvalidJwtException') {
154+
throw err;
155+
}
156+
throw new JWTExceptions.InvalidJwtException('Malformed JWT cannot be parsed', err);
157+
}
158+
}
159+
160+
/**
161+
* Verifies a JWT token using an RSA public key
162+
* @param {string} jwtToken - The JWT token to verify
163+
* @param {Object|string} publicKey - The RSA public key (JWK object or JSON string)
164+
* @throws {InvalidJwtException} - If JWT parsing fails
165+
* @throws {JwtSignatureValidationException} - If signature verification fails
166+
*/
167+
exports.verifyJwt = function (jwtToken, publicKey) {
168+
if (!publicKey) {
169+
throw new JWTExceptions.JwtSignatureValidationException('No public key found');
170+
}
171+
172+
if (!jwtToken) {
173+
throw new JWTExceptions.JwtSignatureValidationException('JWT token is null or undefined');
174+
}
175+
176+
const { header, _, signature, rawHeader, rawPayload } = exports.parse(jwtToken);
177+
178+
const algorithm = header.alg;
179+
if (!algorithm) {
180+
throw new JWTExceptions.JwtSignatureValidationException(ERROR_MESSAGES.MISSING_ALGORITHM);
181+
}
182+
183+
const hashAlgorithm = SUPPORTED_ALGORITHMS[algorithm];
184+
if (!hashAlgorithm) {
185+
throw new JWTExceptions.JwtSignatureValidationException(ERROR_MESSAGES.UNSUPPORTED_ALGORITHM(algorithm));
186+
}
187+
188+
// Validate and parse the JWK public key - let InvalidJwkException bubble up
189+
const jwkKey = validateAndParseJwk(publicKey);
190+
191+
// Convert JWK to PEM format for verification - let InvalidJwkException bubble up
192+
const publicKeyPem = convertJwkToPem(jwkKey);
193+
const signingInput = rawHeader + '.' + rawPayload;
194+
195+
let signatureBuffer;
196+
try {
197+
signatureBuffer = Buffer.from(signature, 'base64url');
198+
} catch (sigDecodeErr) {
199+
throw new JWTExceptions.JwtSignatureValidationException('Invalid base64url encoding in JWT signature', sigDecodeErr);
200+
}
201+
202+
let isValid;
203+
try {
204+
const verifier = crypto.createVerify(hashAlgorithm.toUpperCase());
205+
verifier.update(signingInput);
206+
isValid = verifier.verify(publicKeyPem, signatureBuffer);
207+
} catch (verifyErr) {
208+
throw new JWTExceptions.JwtSignatureValidationException('Signature verification failed', verifyErr);
209+
}
210+
211+
if (!isValid) {
212+
throw new JWTExceptions.JwtSignatureValidationException('JWT signature verification failed');
213+
}
214+
}
215+
216+
/**
217+
* Extracts an RSA public key from a JWK JSON string
218+
* @param {string} jwkJsonString - The JWK JSON string containing the RSA key
219+
* @returns {Object} - The RSA public key object
220+
* @throws {InvalidJwkException} - If the JWK is invalid or not an RSA key
221+
*/
222+
exports.getRSAPublicKeyFromJwk = function (jwkJsonString) {
223+
try {
224+
const jwkData = JSON.parse(jwkJsonString);
225+
if (jwkData.kty !== 'RSA') {
226+
throw new JWTExceptions.InvalidJwkException('JWK Algorithm mismatch. Expected algorithm : RSA');
227+
}
228+
return jwkData;
229+
} catch (err) {
230+
if (err.name === 'InvalidJwkException') {
231+
throw err;
232+
}
233+
throw new JWTExceptions.InvalidJwkException('Failed to parse JWK or extract RSA public key', err);
234+
}
235+
}

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8355,5 +8355,6 @@
83558355
exports.ExternalLoggerWrapper = require('./authentication/logging/ExternalLoggerWrapper.js');
83568356
exports.JWEUtility = require('./utilities/JWEUtility.js');
83578357
exports.SdkTracker = require('./utilities/tracking/SdkTracker.js');
8358+
exports.CaptureContextParsingUtility = require('./utilities/capturecontext/CaptureContextParsingUtility.js');
83588359
return exports;
83598360
}));

0 commit comments

Comments
 (0)