Skip to content

Commit e63d422

Browse files
committed
fix: prevent double callback invocation on payload conflict
When jwt.sign() is called with a callback and the payload already contains a claim (iss, sub, aud, jti) that conflicts with a corresponding option (issuer, subject, audience, jwtid), the callback was being invoked twice: once with the error, and again with the signed token. Root cause: The forEach loop at lines 217-225 used 'return failure()' to report the error, but 'return' inside a forEach callback only exits that callback, not the outer function. The loop continued processing remaining keys, and execution proceeded to sign and return the token via callback. Fix: Replace forEach with a for...of loop so that 'return failure()' correctly exits the outer function immediately upon detecting a conflict. Fixes #1000
1 parent ed59e76 commit e63d422

2 files changed

Lines changed: 137 additions & 2 deletions

File tree

sign.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,15 @@ module.exports = function (payload, secretOrPrivateKey, options, callback) {
214214
}
215215
}
216216

217-
Object.keys(options_to_payload).forEach(function (key) {
217+
for (const key of Object.keys(options_to_payload)) {
218218
const claim = options_to_payload[key];
219219
if (typeof options[key] !== 'undefined') {
220220
if (typeof payload[claim] !== 'undefined') {
221221
return failure(new Error('Bad "options.' + key + '" option. The payload already has an "' + claim + '" property.'));
222222
}
223223
payload[claim] = options[key];
224224
}
225-
});
225+
}
226226

227227
const encoding = options.encoding || 'utf8';
228228

test/issue_1000.tests.js

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
'use strict';
2+
3+
const jwt = require('../');
4+
const expect = require('chai').expect;
5+
6+
describe('issue 1000 - callback invocation count on payload property conflicts', function() {
7+
const secret = 'super-secret-key';
8+
9+
describe('when payload already has a claim that conflicts with options', function() {
10+
const conflictingCases = [
11+
{ option: 'issuer', claim: 'iss', optionValue: 'option-issuer', claimValue: 'payload-issuer' },
12+
{ option: 'subject', claim: 'sub', optionValue: 'option-subject', claimValue: 'payload-subject' },
13+
{ option: 'audience', claim: 'aud', optionValue: 'option-audience', claimValue: 'payload-audience' },
14+
{ option: 'jwtid', claim: 'jti', optionValue: 'option-jwtid', claimValue: 'payload-jwtid' },
15+
];
16+
17+
conflictingCases.forEach(({ option, claim, optionValue, claimValue }) => {
18+
it(`should call callback exactly once when payload has "${claim}" and options has "${option}"`, function(done) {
19+
let callbackCount = 0;
20+
21+
const payload = { data: 'test', [claim]: claimValue };
22+
const options = { [option]: optionValue, algorithm: 'HS256' };
23+
24+
jwt.sign(payload, secret, options, function(err, token) {
25+
callbackCount++;
26+
27+
// Use setImmediate to let any additional callbacks execute first
28+
setImmediate(function() {
29+
expect(callbackCount).to.equal(1, `Callback was called ${callbackCount} times, expected exactly 1`);
30+
expect(err).to.be.instanceOf(Error);
31+
expect(err.message).to.equal(`Bad "options.${option}" option. The payload already has an "${claim}" property.`);
32+
expect(token).to.be.undefined;
33+
done();
34+
});
35+
});
36+
});
37+
});
38+
39+
it('should call callback exactly once with multiple conflicting options', function(done) {
40+
let callbackCount = 0;
41+
42+
const payload = { data: 'test', iss: 'payload-issuer', sub: 'payload-subject' };
43+
const options = { issuer: 'option-issuer', subject: 'option-subject', algorithm: 'HS256' };
44+
45+
jwt.sign(payload, secret, options, function(err, token) {
46+
callbackCount++;
47+
48+
setImmediate(function() {
49+
expect(callbackCount).to.equal(1, `Callback was called ${callbackCount} times, expected exactly 1`);
50+
expect(err).to.be.instanceOf(Error);
51+
// Should error on the first conflict encountered (issuer)
52+
expect(err.message).to.equal('Bad "options.issuer" option. The payload already has an "iss" property.');
53+
expect(token).to.be.undefined;
54+
done();
55+
});
56+
});
57+
});
58+
});
59+
60+
describe('when there are no payload property conflicts', function() {
61+
it('should call callback exactly once with a valid token', function(done) {
62+
let callbackCount = 0;
63+
64+
const payload = { data: 'test' };
65+
const options = { issuer: 'my-issuer', algorithm: 'HS256' };
66+
67+
jwt.sign(payload, secret, options, function(err, token) {
68+
callbackCount++;
69+
70+
setImmediate(function() {
71+
expect(callbackCount).to.equal(1, `Callback was called ${callbackCount} times, expected exactly 1`);
72+
expect(err).to.be.null;
73+
expect(token).to.be.a('string');
74+
75+
// Verify the token contains the expected claims
76+
const decoded = jwt.decode(token);
77+
expect(decoded.data).to.equal('test');
78+
expect(decoded.iss).to.equal('my-issuer');
79+
done();
80+
});
81+
});
82+
});
83+
84+
it('should call callback exactly once when using all options_to_payload options', function(done) {
85+
let callbackCount = 0;
86+
87+
const payload = { data: 'test' };
88+
const options = {
89+
issuer: 'my-issuer',
90+
subject: 'my-subject',
91+
audience: 'my-audience',
92+
jwtid: 'my-jwtid',
93+
algorithm: 'HS256'
94+
};
95+
96+
jwt.sign(payload, secret, options, function(err, token) {
97+
callbackCount++;
98+
99+
setImmediate(function() {
100+
expect(callbackCount).to.equal(1, `Callback was called ${callbackCount} times, expected exactly 1`);
101+
expect(err).to.be.null;
102+
expect(token).to.be.a('string');
103+
104+
const decoded = jwt.decode(token);
105+
expect(decoded.iss).to.equal('my-issuer');
106+
expect(decoded.sub).to.equal('my-subject');
107+
expect(decoded.aud).to.equal('my-audience');
108+
expect(decoded.jti).to.equal('my-jwtid');
109+
done();
110+
});
111+
});
112+
});
113+
});
114+
115+
describe('synchronous sign behavior', function() {
116+
it('should throw exactly once when payload has conflicting claim', function() {
117+
const payload = { data: 'test', iss: 'payload-issuer' };
118+
const options = { issuer: 'option-issuer', algorithm: 'HS256' };
119+
120+
expect(() => jwt.sign(payload, secret, options))
121+
.to.throw('Bad "options.issuer" option. The payload already has an "iss" property.');
122+
});
123+
124+
it('should return a valid token when there are no conflicts', function() {
125+
const payload = { data: 'test' };
126+
const options = { issuer: 'my-issuer', algorithm: 'HS256' };
127+
128+
const token = jwt.sign(payload, secret, options);
129+
expect(token).to.be.a('string');
130+
131+
const decoded = jwt.decode(token);
132+
expect(decoded.iss).to.equal('my-issuer');
133+
});
134+
});
135+
});

0 commit comments

Comments
 (0)